Skip to content

Commit 268c7f0

Browse files
authored
fix: no-invalid-properties false positives for var() in functions (#227)
* fix: no-invalid-properties false positives for var() in functions * use Function visitors to avoid double traversal
1 parent e0c1f0f commit 268c7f0

File tree

2 files changed

+300
-122
lines changed

2 files changed

+300
-122
lines changed

src/rules/no-invalid-properties.js

Lines changed: 156 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,16 @@ export default {
131131
const vars = new Map();
132132

133133
/**
134-
* We need to track this as a stack because we can have nested
135-
* rules that use the `var()` function, and we need to
136-
* ensure that we validate the innermost rule first.
137-
* @type {Array<Map<string,FunctionNodePlain>>}
134+
* @type {Array<{
135+
* valueParts: string[],
136+
* functionPartsStack: string[][],
137+
* valueSegmentLocs: Map<string,CssLocationRange>,
138+
* skipValidation: boolean,
139+
* hadVarSubstitution: boolean,
140+
* resolvedCache: Map<string,string>
141+
* }>}
138142
*/
139-
const replacements = [];
143+
const declStack = [];
140144

141145
const [{ allowUnknownVariables }] = context.options;
142146

@@ -245,26 +249,153 @@ export default {
245249
return null;
246250
}
247251

252+
/**
253+
* Process a var function node and add its resolved value to the value list
254+
* @param {Object} varNode The var() function node
255+
* @param {string[]} valueList Array to collect processed values
256+
* @param {Map<string,CssLocationRange>} valueSegmentLocs Map of rebuilt value segments to their locations
257+
* @param {Map<string, string>} resolvedCache Cache for resolved variable values to prevent redundant lookups
258+
* @returns {boolean} Whether processing was successful
259+
*/
260+
function processVarFunction(
261+
varNode,
262+
valueList,
263+
valueSegmentLocs,
264+
resolvedCache,
265+
) {
266+
const varValue = vars.get(varNode.children[0].name);
267+
268+
if (varValue) {
269+
const resolvedValue = resolveVariable(
270+
varNode.children[0].name,
271+
resolvedCache,
272+
);
273+
if (resolvedValue) {
274+
valueList.push(resolvedValue);
275+
valueSegmentLocs.set(resolvedValue, varNode.loc);
276+
return true;
277+
}
278+
}
279+
280+
// If the variable is not found and doesn't have a fallback value, report it
281+
if (varNode.children.length === 1) {
282+
if (!allowUnknownVariables) {
283+
context.report({
284+
loc: varNode.children[0].loc,
285+
messageId: "unknownVar",
286+
data: { var: varNode.children[0].name },
287+
});
288+
return false;
289+
}
290+
return true;
291+
}
292+
293+
// Handle fallback values
294+
if (varNode.children[2].type !== "Raw") {
295+
return true;
296+
}
297+
298+
const fallbackValue = varNode.children[2].value.trim();
299+
const resolvedFallbackValue = resolveFallback(
300+
fallbackValue,
301+
resolvedCache,
302+
);
303+
if (resolvedFallbackValue) {
304+
valueList.push(resolvedFallbackValue);
305+
valueSegmentLocs.set(resolvedFallbackValue, varNode.loc);
306+
return true;
307+
}
308+
309+
// No valid fallback found
310+
if (!allowUnknownVariables) {
311+
context.report({
312+
loc: varNode.children[0].loc,
313+
messageId: "unknownVar",
314+
data: { var: varNode.children[0].name },
315+
});
316+
return false;
317+
}
318+
319+
return true;
320+
}
321+
248322
return {
249323
"Rule > Block > Declaration"() {
250-
replacements.push(new Map());
324+
declStack.push({
325+
valueParts: [],
326+
functionPartsStack: [],
327+
valueSegmentLocs: new Map(),
328+
skipValidation: false,
329+
hadVarSubstitution: false,
330+
/**
331+
* Cache for resolved variable values within this single declaration.
332+
* Prevents re-resolving the same variable and re-walking long `var()` chains.
333+
*/
334+
resolvedCache: new Map(),
335+
});
251336
},
252337

253-
"Function[name=/^var$/i]"(node) {
254-
const map = replacements.at(-1);
255-
if (!map) {
338+
"Rule > Block > Declaration > Value > *:not(Function)"(node) {
339+
const state = declStack.at(-1);
340+
const text = sourceCode.getText(node).trim();
341+
state.valueParts.push(text);
342+
state.valueSegmentLocs.set(text, node.loc);
343+
},
344+
345+
Function() {
346+
declStack.at(-1).functionPartsStack.push([]);
347+
},
348+
349+
"Function > *:not(Function)"(node) {
350+
const state = declStack.at(-1);
351+
const parts = state.functionPartsStack.at(-1);
352+
const text = sourceCode.getText(node).trim();
353+
parts.push(text);
354+
state.valueSegmentLocs.set(text, node.loc);
355+
},
356+
357+
"Function:exit"(node) {
358+
const state = declStack.at(-1);
359+
if (state.skipValidation) {
256360
return;
257361
}
258362

259-
/*
260-
* Store the custom property name and the function node
261-
* so can use these to validate the value later.
262-
*/
263-
const name = node.children[0].name;
264-
map.set(name, node);
363+
const parts = state.functionPartsStack.pop();
364+
let result;
365+
if (node.name.toLowerCase() === "var") {
366+
const resolvedParts = [];
367+
const success = processVarFunction(
368+
node,
369+
resolvedParts,
370+
state.valueSegmentLocs,
371+
state.resolvedCache,
372+
);
373+
374+
if (!success) {
375+
state.skipValidation = true;
376+
return;
377+
}
378+
379+
if (resolvedParts.length === 0) {
380+
return;
381+
}
382+
383+
state.hadVarSubstitution = true;
384+
result = resolvedParts[0];
385+
} else {
386+
result = `${node.name}(${parts.join(" ")})`;
387+
}
388+
389+
const parentParts = state.functionPartsStack.at(-1);
390+
if (parentParts) {
391+
parentParts.push(result);
392+
} else {
393+
state.valueParts.push(result);
394+
}
265395
},
266396

267397
"Rule > Block > Declaration:exit"(node) {
398+
const state = declStack.pop();
268399
if (node.property.startsWith("--")) {
269400
// store the custom property name and value to validate later
270401
vars.set(node.property, node.value);
@@ -273,106 +404,13 @@ export default {
273404
return;
274405
}
275406

276-
const varsFound = replacements.pop();
407+
if (state.skipValidation) {
408+
return;
409+
}
277410

278-
/** @type {Map<string,CssLocationRange>} */
279-
const valuesWithVarLocs = new Map();
280-
const usingVars = varsFound?.size > 0;
281411
let value = node.value;
282-
283-
if (usingVars) {
284-
const valueList = [];
285-
/**
286-
* Cache for resolved variable values within this single declaration.
287-
* Prevents re-resolving the same variable and re-walking long `var()` chains.
288-
* @type {Map<string,string>}
289-
*/
290-
const resolvedCache = new Map();
291-
const valueNodes = node.value.children;
292-
293-
// When `var()` is used, we store all the values to `valueList` with the replacement of `var()` with there values or fallback values
294-
for (const child of valueNodes) {
295-
// If value is a function starts with `var()`
296-
if (
297-
child.type === "Function" &&
298-
child.name.toLowerCase() === "var"
299-
) {
300-
const varValue = vars.get(child.children[0].name);
301-
302-
// If the variable is found, use its value, otherwise check for fallback values
303-
if (varValue) {
304-
const resolvedValue = resolveVariable(
305-
child.children[0].name,
306-
resolvedCache,
307-
);
308-
if (resolvedValue !== null) {
309-
valueList.push(resolvedValue);
310-
valuesWithVarLocs.set(
311-
resolvedValue,
312-
child.loc,
313-
);
314-
} else {
315-
if (!allowUnknownVariables) {
316-
context.report({
317-
loc: child.children[0].loc,
318-
messageId: "unknownVar",
319-
data: {
320-
var: child.children[0].name,
321-
},
322-
});
323-
return;
324-
}
325-
}
326-
} else {
327-
// If the variable is not found and doesn't have a fallback value, report it
328-
if (child.children.length === 1) {
329-
if (!allowUnknownVariables) {
330-
context.report({
331-
loc: child.children[0].loc,
332-
messageId: "unknownVar",
333-
data: {
334-
var: child.children[0].name,
335-
},
336-
});
337-
338-
return;
339-
}
340-
} else {
341-
// If it has a fallback value, use that
342-
if (child.children[2].type === "Raw") {
343-
const raw =
344-
child.children[2].value.trim();
345-
const resolvedFallbackValue =
346-
resolveFallback(raw, resolvedCache);
347-
if (resolvedFallbackValue !== null) {
348-
valueList.push(
349-
resolvedFallbackValue,
350-
);
351-
valuesWithVarLocs.set(
352-
resolvedFallbackValue,
353-
child.loc,
354-
);
355-
} else if (!allowUnknownVariables) {
356-
context.report({
357-
loc: child.children[0].loc,
358-
messageId: "unknownVar",
359-
data: {
360-
var: child.children[0].name,
361-
},
362-
});
363-
return;
364-
}
365-
}
366-
}
367-
}
368-
} else {
369-
// If the child is not a `var()` function, just add its text to the `valueList`
370-
const valueText = sourceCode.getText(child).trim();
371-
valueList.push(valueText);
372-
valuesWithVarLocs.set(valueText, child.loc);
373-
}
374-
}
375-
412+
if (state.hadVarSubstitution) {
413+
const valueList = state.valueParts;
376414
value =
377415
valueList.length > 0
378416
? valueList.join(" ")
@@ -385,7 +423,7 @@ export default {
385423
// validation failure
386424
if (isSyntaxMatchError(error)) {
387425
const errorValue =
388-
usingVars &&
426+
state.hadVarSubstitution &&
389427
value.slice(
390428
error.mismatchOffset,
391429
error.mismatchOffset + error.mismatchLength,
@@ -398,8 +436,8 @@ export default {
398436
* If so, use that location; otherwise, use the error's
399437
* reported location.
400438
*/
401-
loc: usingVars
402-
? (valuesWithVarLocs.get(errorValue) ??
439+
loc: state.hadVarSubstitution
440+
? (state.valueSegmentLocs.get(errorValue) ??
403441
node.value.loc)
404442
: error.loc,
405443
messageId: "invalidPropertyValue",
@@ -411,12 +449,8 @@ export default {
411449
* only include the part that caused the error.
412450
* Otherwise, use the full value from the error.
413451
*/
414-
value: usingVars
415-
? value.slice(
416-
error.mismatchOffset,
417-
error.mismatchOffset +
418-
error.mismatchLength,
419-
)
452+
value: state.hadVarSubstitution
453+
? errorValue
420454
: error.css,
421455
expected: error.syntax,
422456
},

0 commit comments

Comments
 (0)