From 43bfbe249a6ee5594df2f8063bcf2adfd648aebd Mon Sep 17 00:00:00 2001 From: Igor <86978055+Poliklot@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:48:27 +0300 Subject: [PATCH] Fix missing CSS node locations (#19186) Co-authored-by: fisker --- src/language-css/loc.js | 342 +++++++++++++++++++++++--- src/language-css/massage-ast/index.js | 6 - src/language-css/parse/parse-value.js | 21 +- 3 files changed, 325 insertions(+), 44 deletions(-) diff --git a/src/language-css/loc.js b/src/language-css/loc.js index 7820306ab..9dddc801e 100644 --- a/src/language-css/loc.js +++ b/src/language-css/loc.js @@ -37,6 +37,10 @@ function calculateLocEnd(node, text) { return skipEverythingButNewLine(text, node.source.startOffset); } + if (node.type === "value-paren" && typeof node.sourceIndex === "number") { + return node.sourceIndex + (node.value === ")" ? node.value.length : 0); + } + // `postcss>=8` if (typeof node.source?.end?.offset === "number") { return node.source.end.offset; @@ -54,40 +58,51 @@ function calculateLocEnd(node, text) { if (isNonEmptyArray(node.nodes)) { return calculateLocEnd(node.nodes.at(-1), text); } + + if (node.type === "css-atrule" && typeof node.name === "string") { + return ( + calculateLocStart(node, text) + + 1 + + node.name.length + + node.raws.afterName.length + + node.raws.params.length + ); + } + } + + if (typeof node.sourceIndex === "number" && typeof node.value === "string") { + return node.sourceIndex + node.value.length; } return null; } function calculateLoc(node, text) { - if (node.source) { - node.source.startOffset = calculateLocStart(node, text); - node.source.endOffset = calculateLocEnd(node, text); - } - - for (const key in node) { - const child = node[key]; - - if (key === "source" || !child || typeof child !== "object") { - continue; - } - - if (child.type === "value-root" || child.type === "value-unknown") { - calculateValueNodeLoc( - child, - getValueRootOffset(node), - child.text || child.value, - ); - } else { - calculateLoc(child, text); - } - } + calculateNodeLoc(node, text, 0, false); } -function calculateValueNodeLoc(node, rootOffset, text) { - if (node.source) { - node.source.startOffset = calculateLocStart(node, text) + rootOffset; - node.source.endOffset = calculateLocEnd(node, text) + rootOffset; +function calculateNodeLoc(node, text, rootOffset, isRootOfText) { + if (isRootOfText && typeof node.type === "string") { + node.source ??= {}; + node.source.startOffset = rootOffset; + node.source.endOffset = rootOffset + text.length; + } else if ( + typeof node.source?.startOffset === "number" && + typeof node.source?.endOffset === "number" + ) { + // Already calculated while walking another view of the same nested tree. + } else if (node.source || typeof node.sourceIndex === "number") { + node.source ??= {}; + const maxEndOffset = rootOffset + text.length; + node.source.startOffset = Math.min( + calculateLocStart(node, text) + rootOffset, + maxEndOffset, + ); + const endOffset = calculateLocEnd(node, text); + node.source.endOffset = + typeof endOffset === "number" + ? Math.min(endOffset + rootOffset, maxEndOffset) + : endOffset; } for (const key in node) { @@ -97,7 +112,57 @@ function calculateValueNodeLoc(node, rootOffset, text) { continue; } - calculateValueNodeLoc(child, rootOffset, text); + const children = Array.isArray(child) ? child : [child]; + for (const childNode of children) { + if (!childNode || typeof childNode !== "object") { + continue; + } + + const nestedLoc = getNestedLoc(node, childNode, rootOffset); + + if (nestedLoc) { + calculateNodeLoc(childNode, nestedLoc.text, nestedLoc.rootOffset, true); + } else { + calculateNodeLoc(childNode, text, rootOffset, false); + } + + fillEmptyLocFromParent(childNode, node); + } + } + + fillLocFromChildren(node); + fillEmptyChildLocs(node); +} + +function getNestedLoc(parentNode, node, rootOffset) { + if (node.type === "value-root" || node.type === "value-unknown") { + return { + rootOffset: getValueRootOffset(parentNode), + text: node.text || node.value || "", + }; + } + + if (node.type === "media-query-list" || parentNode.params === node) { + return { + rootOffset: getAtRuleParamsRootOffset(parentNode), + text: parentNode.raws?.params || node.value || "", + }; + } + + if (node.type?.startsWith("selector-")) { + const selectorText = getSelectorText(parentNode, node); + + if (typeof selectorText === "string") { + return { + rootOffset: getSelectorRootOffset( + parentNode, + node, + selectorText, + rootOffset, + ), + text: selectorText, + }; + } } } @@ -119,6 +184,225 @@ function getValueRootOffset(node) { return result; } +function getAtRuleParamsRootOffset(node) { + let result = node.source.startOffset; + + if (node.type === "css-atrule" && typeof node.name === "string") { + result += + 1 + node.name.length + node.raws.afterName.match(/^\s*:?\s*/)[0].length; + } + + return result; +} + +function getSelectorText(parentNode, selectorNode) { + if (typeof selectorNode.raws?.selector === "string") { + return selectorNode.raws.selector; + } + + if (parentNode.selector !== selectorNode) { + return; + } + + if (parentNode.mixin) { + return ( + parentNode.raws.identifier + + parentNode.name + + parentNode.raws.afterName + + parentNode.raws.params + ); + } + + if (typeof parentNode.raws?.selector === "string") { + return parentNode.raws.selector; + } + + if ( + parentNode.customSelector && + typeof parentNode.raws?.params === "string" + ) { + return parentNode.raws.params + .slice(parentNode.customSelector.length) + .trim(); + } + + if (typeof parentNode.raws?.params === "string") { + return parentNode.raws.params; + } + + if ( + parentNode.type === "css-decl" && + typeof parentNode.raws?.value === "string" + ) { + const { value } = parentNode.raws; + + if (parentNode.extend && value.startsWith("extend(")) { + return value.slice("extend(".length, -1); + } + + return value; + } +} + +function getSelectorRootOffset( + parentNode, + selectorNode, + selectorText, + rootOffset, +) { + if ( + typeof selectorNode.sourceIndex === "number" && + typeof selectorNode.raws?.selector === "string" + ) { + return rootOffset + selectorNode.sourceIndex; + } + + if (parentNode.mixin) { + return parentNode.source.startOffset; + } + + if (typeof parentNode.raws?.selector === "string") { + return ( + parentNode.source.startOffset + + getStringOffset(parentNode.raws.selector, selectorText) + ); + } + + if (typeof parentNode.raws?.params === "string") { + return ( + getAtRuleParamsRootOffset(parentNode) + + getStringOffset(parentNode.raws.params, selectorText) + ); + } + + if ( + parentNode.type === "css-decl" && + typeof parentNode.raws?.value === "string" + ) { + return ( + getValueRootOffset(parentNode) + + getStringOffset(parentNode.raws.value, selectorText) + ); + } + + return rootOffset; +} + +function getStringOffset(text, search) { + const index = text.indexOf(search); + return index === -1 ? 0 : index; +} + +function fillLocFromChildren(node) { + if (typeof node.type !== "string") { + return; + } + + const hasOffsets = + typeof node.source?.startOffset === "number" && + typeof node.source?.endOffset === "number"; + const hasLineColumn = node.source?.start && node.source.end; + + if (hasOffsets && hasLineColumn) { + return; + } + + let startOffset = Number.POSITIVE_INFINITY; + let endOffset = Number.NEGATIVE_INFINITY; + let start; + let end; + + for (const key in node) { + if (key === "source" || key === "raws" || key === "spaces") { + continue; + } + + const child = node[key]; + const children = Array.isArray(child) ? child : [child]; + + for (const childNode of children) { + if ( + !childNode || + typeof childNode !== "object" || + typeof childNode.source?.startOffset !== "number" || + typeof childNode.source?.endOffset !== "number" + ) { + continue; + } + + if (childNode.source.startOffset < startOffset) { + startOffset = childNode.source.startOffset; + start = childNode.source.start; + } + + if (childNode.source.endOffset > endOffset) { + endOffset = childNode.source.endOffset; + end = childNode.source.end; + } + } + } + + if (startOffset !== Number.POSITIVE_INFINITY) { + node.source ??= {}; + if (!hasOffsets) { + node.source.startOffset = startOffset; + node.source.endOffset = endOffset; + } + node.source.start ??= start; + node.source.end ??= end; + } +} + +function fillEmptyLocFromParent(node, parentNode) { + if ( + typeof node.type !== "string" || + node.source || + typeof parentNode.source?.startOffset !== "number" || + typeof parentNode.source?.endOffset !== "number" || + !isEmptyLocNode(node) + ) { + return; + } + + node.source = { + startOffset: parentNode.source.startOffset, + endOffset: parentNode.source.startOffset, + start: parentNode.source.start, + end: parentNode.source.start, + }; +} + +function fillEmptyChildLocs(node) { + if ( + typeof node.source?.startOffset !== "number" || + typeof node.source?.endOffset !== "number" + ) { + return; + } + + for (const key in node) { + if (key === "source" || key === "raws" || key === "spaces") { + continue; + } + + const child = node[key]; + const children = Array.isArray(child) ? child : [child]; + + for (const childNode of children) { + if (childNode && typeof childNode === "object") { + fillEmptyLocFromParent(childNode, node); + } + } + } +} + +function isEmptyLocNode(node) { + return ( + (Array.isArray(node.nodes) && node.nodes.length === 0) || + (Array.isArray(node.groups) && node.groups.length === 0) + ); +} + /** * Workaround for a bug: quotes and asterisks in inline comments corrupt loc data of subsequent nodes. * This function replaces the quotes and asterisks with spaces. Later, when the comments are printed, @@ -245,7 +529,7 @@ function replaceQuotesInInlineComments(text) { return text; } -const locStart = (node) => node.source?.startOffset; -const locEnd = (node) => node.source?.endOffset; +const locStart = (node) => node.source.startOffset; +const locEnd = (node) => node.source.endOffset; export { calculateLoc, locEnd, locStart, replaceQuotesInInlineComments }; diff --git a/src/language-css/massage-ast/index.js b/src/language-css/massage-ast/index.js index bb02ae593..0448b512e 100644 --- a/src/language-css/massage-ast/index.js +++ b/src/language-css/massage-ast/index.js @@ -207,12 +207,6 @@ function massageAstNode(original, cloned, parent) { return { type: "value-atword", value: original.groups.map((node) => node.value).join(""), - group: { - open: null, - close: null, - groups: [], - type: "value-paren_group", - }, }; } } diff --git a/src/language-css/parse/parse-value.js b/src/language-css/parse/parse-value.js index 114182346..d33907dee 100644 --- a/src/language-css/parse/parse-value.js +++ b/src/language-css/parse/parse-value.js @@ -44,14 +44,15 @@ function parseValueNode(valueNode, options) { } if (node.type === "func" && node.value === "selector") { - node.group.groups = [ - parseSelector( - getValueRoot(valueNode).text.slice( - node.group.open.sourceIndex + 1, - node.group.close.sourceIndex, - ), - ), - ]; + const selector = getValueRoot(valueNode).text.slice( + node.group.open.sourceIndex + 1, + node.group.close.sourceIndex, + ); + const parsedSelector = parseSelector(selector); + parsedSelector.sourceIndex = node.group.open.sourceIndex + 1; + parsedSelector.raws ??= {}; + parsedSelector.raws.selector = selector; + node.group.groups = [parsedSelector]; } if (node.type === "func" && node.value === "url") { @@ -162,7 +163,9 @@ function parseNestedValue(node, options) { if (key !== "parent") { parseNestedValue(node[key], options); if (key === "nodes") { - node.group = flattenGroups(parseValueNode(node, options)); + if (!(node.type === "atword" && node.nodes.length === 0)) { + node.group = flattenGroups(parseValueNode(node, options)); + } delete node[key]; } }