diff --git a/.changeset/bright-ravens-escape.md b/.changeset/bright-ravens-escape.md new file mode 100644 index 000000000..3561282b5 --- /dev/null +++ b/.changeset/bright-ravens-escape.md @@ -0,0 +1,7 @@ +--- +'@linaria/postcss-linaria': patch +--- + +Preserve source escapes within fields changed by fixers + +When a fixer changes part of a selector or declaration value, the stringifier now compares the original and changed CSS escape tokens. Retained source backslashes remain unchanged, while backslashes added by the fixer are still escaped for the surrounding JavaScript template (#1508). diff --git a/packages/postcss-linaria/__tests__/stringify.test.ts b/packages/postcss-linaria/__tests__/stringify.test.ts index 116326d9d..c8a166e3a 100644 --- a/packages/postcss-linaria/__tests__/stringify.test.ts +++ b/packages/postcss-linaria/__tests__/stringify.test.ts @@ -585,6 +585,65 @@ export const style = { } }); + // https://github.com/callstack/linaria/issues/1508. A fixer can change one + // part of a field while retaining a source-derived escape elsewhere in it. + it('should preserve source backslashes in a declaration changed by a fixer', () => { + const { ast } = createTestAst(` + css\` + .foo { + content: '\\u2022'; + } + \`; + `); + const root = ast.nodes[0] as Root; + const rule = root.nodes[0] as Rule; + const content = rule.nodes[0] as Declaration; + + content.value = content.value.replace(/'/g, '"'); + + expect(ast.toString(syntax)).toEqual( + ` + css\` + .foo { + content: "\\u2022"; + } + \`; + ` + ); + }); + + it('should distinguish retained and introduced backslashes in a changed selector', () => { + const { ast } = createTestAst(` + css\`.foo\\:bar { color: hotpink; }\`; + `); + const root = ast.nodes[0] as Root; + const rule = root.nodes[0] as Rule; + + rule.selector = `.new\\#qux${rule.selector.replace('.foo', '.baz')}`; + + expect(ast.toString(syntax)).toEqual( + ` + css\`.new\\\\#qux.baz\\:bar { color: hotpink; }\`; + ` + ); + }); + + it('should escape duplicate backslashes conservatively when their origin is ambiguous', () => { + const { ast } = createTestAst(` + css\`.foo\\:bar { color: hotpink; }\`; + `); + const root = ast.nodes[0] as Root; + const rule = root.nodes[0] as Rule; + + rule.selector = `.new\\:qux${rule.selector.replace('.foo', '.baz')}`; + + expect(ast.toString(syntax)).toEqual( + ` + css\`.new\\\\:qux.baz\\\\:bar { color: hotpink; }\`; + ` + ); + }); + it('should escape a backtick a rule introduces exactly once', () => { const { ast } = createTestAst(` css\`.foo { color: hotpink; }\`; diff --git a/packages/postcss-linaria/src/originalState.ts b/packages/postcss-linaria/src/originalState.ts index 1f5fd3de4..966bdbce4 100644 --- a/packages/postcss-linaria/src/originalState.ts +++ b/packages/postcss-linaria/src/originalState.ts @@ -91,6 +91,9 @@ export const isOriginalField = ( return originalState !== undefined && originalState.fields[name] === value; }; +export const getOriginalField = (node: AnyNode, name: string): unknown => + originalStates.get(node)?.fields[name]; + export const isOriginalRaw = ( node: AnyNode, name: string, diff --git a/packages/postcss-linaria/src/stringify.ts b/packages/postcss-linaria/src/stringify.ts index ba56cbdab..215e027ca 100644 --- a/packages/postcss-linaria/src/stringify.ts +++ b/packages/postcss-linaria/src/stringify.ts @@ -11,7 +11,11 @@ import type { } from 'postcss'; import Stringifier from 'postcss/lib/stringifier'; -import { isOriginalField, isOriginalRaw } from './originalState'; +import { + getOriginalField, + isOriginalField, + isOriginalRaw, +} from './originalState'; import { placeholderText } from './util'; const commentPlaceholderPattern = new RegExp( @@ -40,8 +44,130 @@ const escapeBacktick = (value: string): string => backslashes.length % 2 === 0 ? `${backslashes}\\\`` : `${backslashes}\`` ); -const escapeChangedField = (value: string): string => - value.replace(/\\/g, '\\\\').replace(/`/g, '\\`'); +interface FieldToken { + isEscape: boolean; + value: string; +} + +const fieldTokenPattern = + /\\(?:[0-9a-fA-F]{1,6}[ \t\r\n\f]?|[\s\S])|[^\\]+|\\$/g; +const maxTrackedTokenComparisons = 65_536; + +const tokenizeField = (value: string): FieldToken[] => + Array.from(value.matchAll(fieldTokenPattern), ([token]) => ({ + isEscape: token.startsWith('\\'), + value: token, + })); + +const tokensEqual = ( + first: FieldToken | undefined, + second: FieldToken | undefined +): boolean => + first !== undefined && + second !== undefined && + first.isEscape === second.isEscape && + first.value === second.value; + +const countEscapeTokens = (tokens: FieldToken[]): Map => + tokens.reduce((counts, token) => { + if (token.isEscape) { + counts.set(token.value, (counts.get(token.value) ?? 0) + 1); + } + return counts; + }, new Map()); + +// A fixer exposes only its complete before/after strings. Comparing CSS escape +// tokens plus their unchanged neighbouring chunks identifies retained escapes +// without a character-sized diff. If an escape's count changes, its occurrences +// are ambiguous and remain fixer-owned. The bound prevents pathological fields +// with thousands of escapes from allocating a large LCS table; those safely +// fall back to treating every escape as fixer-owned. +const findRetainedEscapeTokens = ( + original: FieldToken[], + changed: FieldToken[] +): boolean[] => { + const retained = new Array(changed.length).fill(false); + if (original.length * changed.length > maxTrackedTokenComparisons) { + return retained; + } + + const originalEscapeCounts = countEscapeTokens(original); + const changedEscapeCounts = countEscapeTokens(changed); + const unambiguousEscapes = new Set(); + originalEscapeCounts.forEach((count, escape) => { + if (changedEscapeCounts.get(escape) === count) { + unambiguousEscapes.add(escape); + } + }); + + const columns = changed.length + 1; + const lengths = new Uint16Array((original.length + 1) * columns); + for ( + let originalIndex = original.length - 1; + originalIndex >= 0; + originalIndex -= 1 + ) { + for ( + let changedIndex = changed.length - 1; + changedIndex >= 0; + changedIndex -= 1 + ) { + const index = originalIndex * columns + changedIndex; + lengths[index] = tokensEqual( + original[originalIndex], + changed[changedIndex] + ) + ? (lengths[index + columns + 1] ?? 0) + 1 + : Math.max(lengths[index + columns] ?? 0, lengths[index + 1] ?? 0); + } + } + + let originalIndex = 0; + let changedIndex = 0; + while (originalIndex < original.length && changedIndex < changed.length) { + const originalToken = original[originalIndex]; + const changedToken = changed[changedIndex]; + if (tokensEqual(originalToken, changedToken)) { + retained[changedIndex] = + changedToken?.isEscape === true && + unambiguousEscapes.has(changedToken.value); + originalIndex += 1; + changedIndex += 1; + } else if ( + (lengths[(originalIndex + 1) * columns + changedIndex] ?? 0) >= + (lengths[originalIndex * columns + changedIndex + 1] ?? 0) + ) { + originalIndex += 1; + } else { + changedIndex += 1; + } + } + + return retained; +}; + +const escapeChangedField = (value: string, originalValue?: string): string => { + if ( + originalValue === undefined || + !originalValue.includes('\\') || + !value.includes('\\') + ) { + return value.replace(/\\/g, '\\\\').replace(/`/g, '\\`'); + } + + const originalTokens = tokenizeField(originalValue); + const changedTokens = tokenizeField(value); + const retained = findRetainedEscapeTokens(originalTokens, changedTokens); + const escaped = changedTokens + .map((token, index) => + token.isEscape && !retained[index] + ? token.value.replace(/\\/g, '\\\\') + : token.value + ) + .join(''); + + return escapeBacktick(escaped); +}; const rawValueFields = new Set(['params', 'selector', 'value']); @@ -93,11 +219,19 @@ const escapeNodeField = ( value: string ): string => { const currentValue = (node as unknown as Record)[name]; + const originalValue = getOriginalField(node, name); const currentRaw = (node.raws as Record)[name]; const isSourceDerived = isOriginalField(node, name, currentValue) && (!rawValueFields.has(name) || isOriginalRaw(node, name, currentRaw)); - return isSourceDerived ? escapeBacktick(value) : escapeChangedField(value); + if (isSourceDerived) return escapeBacktick(value); + + return escapeChangedField( + value, + typeof originalValue === 'string' && currentValue === value + ? originalValue + : undefined + ); }; const escapeRawField = (node: AnyNode, name: string, value: string): string => {