Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 178 additions & 89 deletions src/rules/no-invalid-properties.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ import { isSyntaxMatchError, isSyntaxReferenceError } from "../util.js";
// Helpers
//-----------------------------------------------------------------------------

/**
* Regex to match var() functional notation with optional fallback.
*/
const varFunctionPattern = /var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/iu;

/**
* Parses a var() function text and extracts the custom property name and fallback.
* @param {string} text
* @returns {{ name: string, fallbackText: string | null } | null}
*/
function parseVarFunction(text) {
const match = text.match(varFunctionPattern);
if (!match) {
return null;
}
return {
name: match[1].trim(),
fallbackText: match[2]?.trim(),
};
}

/**
* 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"]`.
Expand All @@ -36,31 +57,26 @@ function getVarFallbackList(value) {
let currentValue = value;

while (true) {
const match = currentValue.match(
/var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/iu,
);
const parsed = parseVarFunction(currentValue);

if (!match) {
if (!parsed) {
break;
}

const prop = match[1].trim();
const fallback = match[2]?.trim();

list.push(prop);
list.push(parsed.name);

if (!fallback) {
if (!parsed.fallbackText) {
break;
}

// If fallback is not another var(), we're done
if (!fallback.toLowerCase().includes("var(")) {
list.push(fallback);
if (!parsed.fallbackText.toLowerCase().includes("var(")) {
list.push(parsed.fallbackText);
break;
}

// Continue parsing from fallback
currentValue = fallback;
currentValue = parsed.fallbackText;
}

return list;
Expand Down Expand Up @@ -124,6 +140,111 @@ export default {

const [{ allowUnknownVariables }] = context.options;

/**
* Iteratively resolves CSS variable references until a value is found.
* @param {string} variableName The variable name to resolve
* @param {Map<string, string>} cache Cache for memoization within a single resolution scope
* @param {Set<string>} [seen] Set of already seen variables to detect cycles
* @returns {string|null} The resolved value or null if not found
*/
function resolveVariable(variableName, cache, seen = new Set()) {
/** @type {Array<string>} */
const fallbackStack = [];
let currentVarName = variableName;

/*
* Resolves a CSS variable by following its reference chain.
*
* Phase 1: Follow var() references
* - Use `seen` to detect cycles
* - Use `cache` for memoization
* - If value is concrete: cache and return
* - If value is another var(--next, <fallback>):
* push fallback to stack and continue with --next
* - If variable unknown: proceed to Phase 2
*
* Phase 2: Try fallback values (if Phase 1 failed)
* - Process fallbacks in reverse order (LIFO)
* - Resolve each via resolveFallback()
* - Return first successful resolution
*/
while (true) {
if (seen.has(currentVarName)) {
break;
}
seen.add(currentVarName);

if (cache.has(currentVarName)) {
return cache.get(currentVarName);
}

const valueNode = vars.get(currentVarName);
if (!valueNode) {
break;
}

const valueText = sourceCode.getText(valueNode).trim();
const parsed = parseVarFunction(valueText);

if (!parsed) {
cache.set(currentVarName, valueText);
return valueText;
}

if (parsed.fallbackText) {
fallbackStack.push(parsed.fallbackText);
}
currentVarName = parsed.name;
}

while (fallbackStack.length > 0) {
const fallbackText = fallbackStack.pop();
// eslint-disable-next-line no-use-before-define -- resolveFallback and resolveVariable are mutually recursive
const resolvedFallback = resolveFallback(
fallbackText,
cache,
seen,
);
if (resolvedFallback !== null) {
return resolvedFallback;
}
}

return null;
}

/**
* Resolves a fallback text which can contain nested var() calls.
* Returns the first resolvable value or null if none resolve.
* @param {string} rawFallbackText
* @param {Map<string, string>} cache Cache for memoization within a single resolution scope
* @param {Set<string>} [seen] Set of already seen variables to detect cycles
* @returns {string | null}
*/
function resolveFallback(rawFallbackText, cache, seen = new Set()) {
const fallbackVarList = getVarFallbackList(rawFallbackText);
if (fallbackVarList.length === 0) {
return rawFallbackText;
}

for (const fallbackCandidate of fallbackVarList) {
if (fallbackCandidate.startsWith("--")) {
const resolved = resolveVariable(
fallbackCandidate,
cache,
seen,
);
if (resolved !== null) {
return resolved;
}
continue;
}
return fallbackCandidate.trim();
}

return null;
}

return {
"Rule > Block > Declaration"() {
replacements.push(new Map());
Expand Down Expand Up @@ -161,6 +282,12 @@ export default {

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<string,string>}
*/
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
Expand All @@ -174,12 +301,28 @@ export default {

// 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);
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) {
Expand All @@ -197,81 +340,27 @@ export default {
} else {
// If it has a fallback value, use that
if (child.children[2].type === "Raw") {
const fallbackVarList =
getVarFallbackList(
child.children[2].value.trim(),
const raw =
child.children[2].value.trim();
const resolvedFallbackValue =
resolveFallback(raw, resolvedCache);
if (resolvedFallbackValue !== null) {
valueList.push(
resolvedFallbackValue,
);
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,
resolvedFallbackValue,
child.loc,
);
} else if (!allowUnknownVariables) {
context.report({
loc: child.children[0].loc,
messageId: "unknownVar",
data: {
var: child.children[0].name,
},
});
return;
}
}
}
Expand Down
Loading
Loading