@@ -11,7 +11,11 @@ import type {
1111} from 'postcss' ;
1212import Stringifier from 'postcss/lib/stringifier' ;
1313
14- import { isOriginalField , isOriginalRaw } from './originalState' ;
14+ import {
15+ getOriginalField ,
16+ isOriginalField ,
17+ isOriginalRaw ,
18+ } from './originalState' ;
1519import { placeholderText } from './util' ;
1620
1721const commentPlaceholderPattern = new RegExp (
@@ -40,8 +44,130 @@ const escapeBacktick = (value: string): string =>
4044 backslashes . length % 2 === 0 ? `${ backslashes } \\\`` : `${ backslashes } \``
4145 ) ;
4246
43- const escapeChangedField = ( value : string ) : string =>
44- value . replace ( / \\ / g, '\\\\' ) . replace ( / ` / g, '\\`' ) ;
47+ interface FieldToken {
48+ isEscape : boolean ;
49+ value : string ;
50+ }
51+
52+ const fieldTokenPattern =
53+ / \\ (?: [ 0 - 9 a - f A - F ] { 1 , 6 } [ \t \r \n \f ] ? | [ \s \S ] ) | [ ^ \\ ] + | \\ $ / g;
54+ const maxTrackedTokenComparisons = 65_536 ;
55+
56+ const tokenizeField = ( value : string ) : FieldToken [ ] =>
57+ Array . from ( value . matchAll ( fieldTokenPattern ) , ( [ token ] ) => ( {
58+ isEscape : token . startsWith ( '\\' ) ,
59+ value : token ,
60+ } ) ) ;
61+
62+ const tokensEqual = (
63+ first : FieldToken | undefined ,
64+ second : FieldToken | undefined
65+ ) : boolean =>
66+ first !== undefined &&
67+ second !== undefined &&
68+ first . isEscape === second . isEscape &&
69+ first . value === second . value ;
70+
71+ const countEscapeTokens = ( tokens : FieldToken [ ] ) : Map < string , number > =>
72+ tokens . reduce ( ( counts , token ) => {
73+ if ( token . isEscape ) {
74+ counts . set ( token . value , ( counts . get ( token . value ) ?? 0 ) + 1 ) ;
75+ }
76+ return counts ;
77+ } , new Map < string , number > ( ) ) ;
78+
79+ // A fixer exposes only its complete before/after strings. Comparing CSS escape
80+ // tokens plus their unchanged neighbouring chunks identifies retained escapes
81+ // without a character-sized diff. If an escape's count changes, its occurrences
82+ // are ambiguous and remain fixer-owned. The bound prevents pathological fields
83+ // with thousands of escapes from allocating a large LCS table; those safely
84+ // fall back to treating every escape as fixer-owned.
85+ const findRetainedEscapeTokens = (
86+ original : FieldToken [ ] ,
87+ changed : FieldToken [ ]
88+ ) : boolean [ ] => {
89+ const retained = new Array < boolean > ( changed . length ) . fill ( false ) ;
90+ if ( original . length * changed . length > maxTrackedTokenComparisons ) {
91+ return retained ;
92+ }
93+
94+ const originalEscapeCounts = countEscapeTokens ( original ) ;
95+ const changedEscapeCounts = countEscapeTokens ( changed ) ;
96+ const unambiguousEscapes = new Set < string > ( ) ;
97+ originalEscapeCounts . forEach ( ( count , escape ) => {
98+ if ( changedEscapeCounts . get ( escape ) === count ) {
99+ unambiguousEscapes . add ( escape ) ;
100+ }
101+ } ) ;
102+
103+ const columns = changed . length + 1 ;
104+ const lengths = new Uint16Array ( ( original . length + 1 ) * columns ) ;
105+ for (
106+ let originalIndex = original . length - 1 ;
107+ originalIndex >= 0 ;
108+ originalIndex -= 1
109+ ) {
110+ for (
111+ let changedIndex = changed . length - 1 ;
112+ changedIndex >= 0 ;
113+ changedIndex -= 1
114+ ) {
115+ const index = originalIndex * columns + changedIndex ;
116+ lengths [ index ] = tokensEqual (
117+ original [ originalIndex ] ,
118+ changed [ changedIndex ]
119+ )
120+ ? ( lengths [ index + columns + 1 ] ?? 0 ) + 1
121+ : Math . max ( lengths [ index + columns ] ?? 0 , lengths [ index + 1 ] ?? 0 ) ;
122+ }
123+ }
124+
125+ let originalIndex = 0 ;
126+ let changedIndex = 0 ;
127+ while ( originalIndex < original . length && changedIndex < changed . length ) {
128+ const originalToken = original [ originalIndex ] ;
129+ const changedToken = changed [ changedIndex ] ;
130+ if ( tokensEqual ( originalToken , changedToken ) ) {
131+ retained [ changedIndex ] =
132+ changedToken ?. isEscape === true &&
133+ unambiguousEscapes . has ( changedToken . value ) ;
134+ originalIndex += 1 ;
135+ changedIndex += 1 ;
136+ } else if (
137+ ( lengths [ ( originalIndex + 1 ) * columns + changedIndex ] ?? 0 ) >=
138+ ( lengths [ originalIndex * columns + changedIndex + 1 ] ?? 0 )
139+ ) {
140+ originalIndex += 1 ;
141+ } else {
142+ changedIndex += 1 ;
143+ }
144+ }
145+
146+ return retained ;
147+ } ;
148+
149+ const escapeChangedField = ( value : string , originalValue ?: string ) : string => {
150+ if (
151+ originalValue === undefined ||
152+ ! originalValue . includes ( '\\' ) ||
153+ ! value . includes ( '\\' )
154+ ) {
155+ return value . replace ( / \\ / g, '\\\\' ) . replace ( / ` / g, '\\`' ) ;
156+ }
157+
158+ const originalTokens = tokenizeField ( originalValue ) ;
159+ const changedTokens = tokenizeField ( value ) ;
160+ const retained = findRetainedEscapeTokens ( originalTokens , changedTokens ) ;
161+ const escaped = changedTokens
162+ . map ( ( token , index ) =>
163+ token . isEscape && ! retained [ index ]
164+ ? token . value . replace ( / \\ / g, '\\\\' )
165+ : token . value
166+ )
167+ . join ( '' ) ;
168+
169+ return escapeBacktick ( escaped ) ;
170+ } ;
45171
46172const rawValueFields = new Set ( [ 'params' , 'selector' , 'value' ] ) ;
47173
@@ -93,11 +219,19 @@ const escapeNodeField = (
93219 value : string
94220) : string => {
95221 const currentValue = ( node as unknown as Record < string , unknown > ) [ name ] ;
222+ const originalValue = getOriginalField ( node , name ) ;
96223 const currentRaw = ( node . raws as Record < string , unknown > ) [ name ] ;
97224 const isSourceDerived =
98225 isOriginalField ( node , name , currentValue ) &&
99226 ( ! rawValueFields . has ( name ) || isOriginalRaw ( node , name , currentRaw ) ) ;
100- return isSourceDerived ? escapeBacktick ( value ) : escapeChangedField ( value ) ;
227+ if ( isSourceDerived ) return escapeBacktick ( value ) ;
228+
229+ return escapeChangedField (
230+ value ,
231+ typeof originalValue === 'string' && currentValue === value
232+ ? originalValue
233+ : undefined
234+ ) ;
101235} ;
102236
103237const escapeRawField = ( node : AnyNode , name : string , value : string ) : string => {
0 commit comments