diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index c83b742..6360df0 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -131,12 +131,16 @@ export default { const vars = new Map(); /** - * We need to track this as a stack because we can have nested - * rules that use the `var()` function, and we need to - * ensure that we validate the innermost rule first. - * @type {Array>} + * @type {Array<{ + * valueParts: string[], + * functionPartsStack: string[][], + * valueSegmentLocs: Map, + * skipValidation: boolean, + * hadVarSubstitution: boolean, + * resolvedCache: Map + * }>} */ - const replacements = []; + const declStack = []; const [{ allowUnknownVariables }] = context.options; @@ -245,26 +249,153 @@ export default { return null; } + /** + * Process a var function node and add its resolved value to the value list + * @param {Object} varNode The var() function node + * @param {string[]} valueList Array to collect processed values + * @param {Map} valueSegmentLocs Map of rebuilt value segments to their locations + * @param {Map} resolvedCache Cache for resolved variable values to prevent redundant lookups + * @returns {boolean} Whether processing was successful + */ + function processVarFunction( + varNode, + valueList, + valueSegmentLocs, + resolvedCache, + ) { + const varValue = vars.get(varNode.children[0].name); + + if (varValue) { + const resolvedValue = resolveVariable( + varNode.children[0].name, + resolvedCache, + ); + if (resolvedValue) { + valueList.push(resolvedValue); + valueSegmentLocs.set(resolvedValue, varNode.loc); + return true; + } + } + + // If the variable is not found and doesn't have a fallback value, report it + if (varNode.children.length === 1) { + if (!allowUnknownVariables) { + context.report({ + loc: varNode.children[0].loc, + messageId: "unknownVar", + data: { var: varNode.children[0].name }, + }); + return false; + } + return true; + } + + // Handle fallback values + if (varNode.children[2].type !== "Raw") { + return true; + } + + const fallbackValue = varNode.children[2].value.trim(); + const resolvedFallbackValue = resolveFallback( + fallbackValue, + resolvedCache, + ); + if (resolvedFallbackValue) { + valueList.push(resolvedFallbackValue); + valueSegmentLocs.set(resolvedFallbackValue, varNode.loc); + return true; + } + + // No valid fallback found + if (!allowUnknownVariables) { + context.report({ + loc: varNode.children[0].loc, + messageId: "unknownVar", + data: { var: varNode.children[0].name }, + }); + return false; + } + + return true; + } + return { "Rule > Block > Declaration"() { - replacements.push(new Map()); + declStack.push({ + valueParts: [], + functionPartsStack: [], + valueSegmentLocs: new Map(), + skipValidation: false, + hadVarSubstitution: false, + /** + * Cache for resolved variable values within this single declaration. + * Prevents re-resolving the same variable and re-walking long `var()` chains. + */ + resolvedCache: new Map(), + }); }, - "Function[name=/^var$/i]"(node) { - const map = replacements.at(-1); - if (!map) { + "Rule > Block > Declaration > Value > *:not(Function)"(node) { + const state = declStack.at(-1); + const text = sourceCode.getText(node).trim(); + state.valueParts.push(text); + state.valueSegmentLocs.set(text, node.loc); + }, + + Function() { + declStack.at(-1).functionPartsStack.push([]); + }, + + "Function > *:not(Function)"(node) { + const state = declStack.at(-1); + const parts = state.functionPartsStack.at(-1); + const text = sourceCode.getText(node).trim(); + parts.push(text); + state.valueSegmentLocs.set(text, node.loc); + }, + + "Function:exit"(node) { + const state = declStack.at(-1); + if (state.skipValidation) { return; } - /* - * Store the custom property name and the function node - * so can use these to validate the value later. - */ - const name = node.children[0].name; - map.set(name, node); + const parts = state.functionPartsStack.pop(); + let result; + if (node.name.toLowerCase() === "var") { + const resolvedParts = []; + const success = processVarFunction( + node, + resolvedParts, + state.valueSegmentLocs, + state.resolvedCache, + ); + + if (!success) { + state.skipValidation = true; + return; + } + + if (resolvedParts.length === 0) { + return; + } + + state.hadVarSubstitution = true; + result = resolvedParts[0]; + } else { + result = `${node.name}(${parts.join(" ")})`; + } + + const parentParts = state.functionPartsStack.at(-1); + if (parentParts) { + parentParts.push(result); + } else { + state.valueParts.push(result); + } }, "Rule > Block > Declaration:exit"(node) { + const state = declStack.pop(); if (node.property.startsWith("--")) { // store the custom property name and value to validate later vars.set(node.property, node.value); @@ -273,106 +404,13 @@ export default { return; } - const varsFound = replacements.pop(); + if (state.skipValidation) { + return; + } - /** @type {Map} */ - const valuesWithVarLocs = new Map(); - const usingVars = varsFound?.size > 0; let value = node.value; - - if (usingVars) { - const valueList = []; - /** - * Cache for resolved variable values within this single declaration. - * Prevents re-resolving the same variable and re-walking long `var()` chains. - * @type {Map} - */ - const resolvedCache = new Map(); - 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.toLowerCase() === "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 resolvedValue = resolveVariable( - child.children[0].name, - resolvedCache, - ); - if (resolvedValue !== null) { - valueList.push(resolvedValue); - valuesWithVarLocs.set( - resolvedValue, - child.loc, - ); - } else { - if (!allowUnknownVariables) { - context.report({ - loc: child.children[0].loc, - messageId: "unknownVar", - data: { - var: child.children[0].name, - }, - }); - return; - } - } - } 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 raw = - child.children[2].value.trim(); - const resolvedFallbackValue = - resolveFallback(raw, resolvedCache); - if (resolvedFallbackValue !== null) { - valueList.push( - resolvedFallbackValue, - ); - valuesWithVarLocs.set( - resolvedFallbackValue, - child.loc, - ); - } else if (!allowUnknownVariables) { - context.report({ - loc: child.children[0].loc, - messageId: "unknownVar", - data: { - var: child.children[0].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); - } - } - + if (state.hadVarSubstitution) { + const valueList = state.valueParts; value = valueList.length > 0 ? valueList.join(" ") @@ -385,7 +423,7 @@ export default { // validation failure if (isSyntaxMatchError(error)) { const errorValue = - usingVars && + state.hadVarSubstitution && value.slice( error.mismatchOffset, error.mismatchOffset + error.mismatchLength, @@ -398,8 +436,8 @@ export default { * If so, use that location; otherwise, use the error's * reported location. */ - loc: usingVars - ? (valuesWithVarLocs.get(errorValue) ?? + loc: state.hadVarSubstitution + ? (state.valueSegmentLocs.get(errorValue) ?? node.value.loc) : error.loc, messageId: "invalidPropertyValue", @@ -411,12 +449,8 @@ export default { * only include the part that caused the error. * Otherwise, use the full value from the error. */ - value: usingVars - ? value.slice( - error.mismatchOffset, - error.mismatchOffset + - error.mismatchLength, - ) + value: state.hadVarSubstitution + ? errorValue : error.css, expected: error.syntax, }, diff --git a/tests/rules/no-invalid-properties.test.js b/tests/rules/no-invalid-properties.test.js index 2f3544e..a8a3b00 100644 --- a/tests/rules/no-invalid-properties.test.js +++ b/tests/rules/no-invalid-properties.test.js @@ -55,6 +55,39 @@ ruleTester.run("no-invalid-properties", rule, { ":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); }", + "a { width: calc(var(--my-width, 100px)) }", + ":root { --my-heading: 3rem; }\na { width: calc(var(--my-width, 100px)) }", + ":root { --my-heading: 3rem; --foo: 100px }\na { width: calc(var(--my-width, var(--foo, 200px))) }", + ":root { --my-heading: 3rem; }\na { width: calc(var(--my-width, var(--foo, 200px))) }", + "a { width: calc(var(--my-width, var(--foo, var(--bar, 200px)))) }", + ":root { --my-width: 100px; }\na { width: calc(var(--my-width, 200px)) }", + ":root { --dynamic-width: calc(20px + 10px); }\na { width: calc(100% - var(--dynamic-width)); }", + ":root { --dynamic-width: calc(20px + 10px); --dynamic-width-2: calc(var(--dynamic-width) + 10px); }\na { width: calc(100% - var(--dynamic-width-2)); }", + ":root { --my-fallback: 100px; }\na { width: calc(var(--my-width, var(--my-fallback))) }", + ":root { --my-fallback: 100px; }\na { width: calc(var(--my-width, var(--my-fallback, 200px))) }", + ":root { --foo: 100px; }\na { width: calc(var(--my-width, var(--my-fallback, var(--foo)))) }", + "a { width: calc(var(--my-width, var(--my-fallback, var(--foo, 200px)))) }", + "a { background-image: linear-gradient(90deg, red, var(--c, blue)); }", + ":root { --my-width: 100px; }\na { width: calc(var(--my-width, var(--fallback-width))) }", + ":root { --my-width: 100px; --fallback-width: 200px; }\na { width: calc(var(--my-width, var(--fallback-width))) }", + ":root { --my-width: 100px; }\na { width: calc(var(--my-width, var(--fallback-width, 200px))) }", + ":root { --my-width: 100px; }\na { width: calc(var(--my-width, var(--fallback-width, var(--foo)))) }", + ":root { --my-width: 100px; }\na { width: calc(var(--my-width, var(--fallback-width, var(--foo, 200px)))) }", + ":root { --my-width: 100px; }\na { width: calc(var(--my-width, var(--fallback-width, var(--foo, var(--bar))))) }", + ":root { --my-width: 100px; }\na { width: calc(var(--my-width, var(--fallback-width, var(--foo, var(--bar, 200px))))) }", + ":root { --width: 1px; }\na { border-top: calc(var(--width, 2px)) var(--style, var(--fallback, solid)) red; }", + ":root { --width: 100px; }\na { width: calc(calc(100% - var(--width))); }", + ":root { --width: 100px; }\na { width: calc(calc(var(--width) + 50px) - 25px); }", + ":root { --color: red; }\na { background: linear-gradient(to right, var(--color), blue); }", + ":root { --color: red; --offset: 10px; }\na { transform: translateX(calc(var(--offset) + 20px)); }", + ":root { --width: 100px; }\na { width: clamp(50px, var(--width), 200px); }", + ":root { --width: 100px; }\na { width: min(var(--width), 150px); }", + ":root { --width: 100px; }\na { width: max(var(--width), 50px); }", + ":root { --width: 100px; }\na { width: calc(min(var(--width), 150px) + 10px); }", + ":root { --width: 100px; }\na { width: calc(max(var(--width), 50px) - 5px); }", + ":root { --width: 100px; }\na { width: calc(clamp(50px, var(--width), 200px) / 2); }", + ":root { --color: red; }\na { filter: drop-shadow(0 0 10px var(--color)); }", + ":root { --color: red; }\na { background: radial-gradient(circle, var(--color), transparent); }", "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)) }", @@ -101,6 +134,10 @@ ruleTester.run("no-invalid-properties", rule, { code: "a { color: VAR(--my-color); }", options: [{ allowUnknownVariables: true }], }, + { + code: "a { width: calc(var(--width)); }", + options: [{ allowUnknownVariables: true }], + }, { code: "a { --my-color: red; color: var(--my-color); background-color: var(--unknown-var); }", options: [{ allowUnknownVariables: true }], @@ -1049,5 +1086,112 @@ ruleTester.run("no-invalid-properties", rule, { }, ], }, + { + code: "a { background-image: linear-gradient(90deg, 45deg, var(--c, blue)); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "background-image", + value: "45deg", + expected: "#", + }, + line: 1, + column: 46, + endLine: 1, + endColumn: 51, + }, + ], + }, + { + code: "a { padding: calc(var(--padding-top, 1px) + 1px) 2px calc(var(--padding-bottom) + 1px); }", + errors: [ + { + messageId: "unknownVar", + data: { + var: "--padding-bottom", + }, + line: 1, + column: 63, + endLine: 1, + endColumn: 79, + }, + ], + }, + { + code: "a { padding: calc(var(--padding-top, var(--fallback))) 2px calc(var(--padding-bottom)); }", + errors: [ + { + messageId: "unknownVar", + data: { + var: "--padding-top", + }, + line: 1, + column: 23, + endLine: 1, + endColumn: 36, + }, + ], + }, + { + code: ":root { --width: 100px }\na { widthh: calc(var(--width, 200px)); }", + errors: [ + { + messageId: "unknownProperty", + data: { + property: "widthh", + }, + line: 2, + column: 5, + endLine: 2, + endColumn: 11, + }, + ], + }, + { + code: "a { padding: calc(max(var(--padding-top, var(--fallback))), 1px) 2px calc(var(--padding-bottom)); }", + errors: [ + { + messageId: "unknownVar", + data: { + var: "--padding-top", + }, + line: 1, + column: 27, + endLine: 1, + endColumn: 40, + }, + ], + }, + { + code: "a { color: rgba(calc(var(--red, 255) + var(--green)), 0, calc(var(--blue)), 1); }", + errors: [ + { + messageId: "unknownVar", + data: { + var: "--green", + }, + line: 1, + column: 44, + endLine: 1, + endColumn: 51, + }, + ], + }, + { + code: "a { transform: translateX(calc(var(--offset-x, min(var(--default-offset, 5px), 10px))) rotate(var(--rotation))); }", + errors: [ + { + messageId: "unknownVar", + data: { + var: "--rotation", + }, + line: 1, + column: 99, + endLine: 1, + endColumn: 109, + }, + ], + }, ], });