Skip to content

Commit 92a43d6

Browse files
Speed up template migrations (#14679)
This PR does two things: - Computes UTF-16 string positions in Rust rather than in JS — eliminating a significant number of traversals of the input string - Applies replacements to the content in ascending order so we only ever move forward through the source string — this lets v8 optimize string concatenation
1 parent be6c69e commit 92a43d6

File tree

8 files changed

+189
-53
lines changed

8 files changed

+189
-53
lines changed

crates/node/src/lib.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
use utf16::IndexConverter;
2+
13
#[macro_use]
24
extern crate napi_derive;
35

6+
mod utf16;
7+
48
#[derive(Debug, Clone)]
59
#[napi(object)]
610
pub struct ChangedContent {
@@ -123,13 +127,25 @@ impl Scanner {
123127
&mut self,
124128
input: ChangedContent,
125129
) -> Vec<CandidateWithPosition> {
130+
let content = input.content.unwrap_or_else(|| {
131+
std::fs::read_to_string(&input.file.unwrap()).expect("Failed to read file")
132+
});
133+
134+
let input = ChangedContent {
135+
file: None,
136+
content: Some(content.clone()),
137+
extension: input.extension,
138+
};
139+
140+
let mut utf16_idx = IndexConverter::new(&content[..]);
141+
126142
self
127143
.scanner
128144
.get_candidates_with_positions(input.into())
129145
.into_iter()
130146
.map(|(candidate, position)| CandidateWithPosition {
131147
candidate,
132-
position: position as i64,
148+
position: utf16_idx.get(position),
133149
})
134150
.collect()
135151
}

crates/node/src/utf16.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/// The `IndexConverter` is used to convert UTF-8 *BYTE* indexes to UTF-16
2+
/// *character* indexes
3+
#[derive(Clone)]
4+
pub struct IndexConverter<'a> {
5+
input: &'a str,
6+
curr_utf8: usize,
7+
curr_utf16: usize,
8+
}
9+
10+
impl<'a> IndexConverter<'a> {
11+
pub fn new(input: &'a str) -> Self {
12+
Self {
13+
input,
14+
curr_utf8: 0,
15+
curr_utf16: 0,
16+
}
17+
}
18+
19+
pub fn get(&mut self, pos: usize) -> i64 {
20+
#[cfg(debug_assertions)]
21+
if self.curr_utf8 > self.input.len() {
22+
panic!("curr_utf8 points past the end of the input string");
23+
}
24+
25+
if pos < self.curr_utf8 {
26+
self.curr_utf8 = 0;
27+
self.curr_utf16 = 0;
28+
}
29+
30+
// SAFETY: No matter what `pos` is passed into this function `curr_utf8`
31+
// will only ever be incremented up to the length of the input string.
32+
//
33+
// This eliminates a "potential" panic that cannot actually happen
34+
let slice = unsafe {
35+
self.input.get_unchecked(self.curr_utf8..)
36+
};
37+
38+
for c in slice.chars() {
39+
if self.curr_utf8 >= pos {
40+
break
41+
}
42+
43+
self.curr_utf8 += c.len_utf8();
44+
self.curr_utf16 += c.len_utf16();
45+
}
46+
47+
return self.curr_utf16 as i64;
48+
}
49+
}
50+
51+
#[cfg(test)]
52+
mod test {
53+
use super::*;
54+
use std::collections::HashMap;
55+
56+
#[test]
57+
fn test_index_converter() {
58+
let mut converter = IndexConverter::new("Hello 🔥🥳 world!");
59+
60+
let map = HashMap::from([
61+
// hello<space>
62+
(0, 0),
63+
(1, 1),
64+
(2, 2),
65+
(3, 3),
66+
(4, 4),
67+
(5, 5),
68+
(6, 6),
69+
70+
// inside the 🔥
71+
(7, 8),
72+
(8, 8),
73+
(9, 8),
74+
(10, 8),
75+
76+
// inside the 🥳
77+
(11, 10),
78+
(12, 10),
79+
(13, 10),
80+
(14, 10),
81+
82+
// <space>world!
83+
(15, 11),
84+
(16, 12),
85+
(17, 13),
86+
(18, 14),
87+
(19, 15),
88+
(20, 16),
89+
(21, 17),
90+
91+
// Past the end should return the last utf-16 character index
92+
(22, 17),
93+
(100, 17),
94+
]);
95+
96+
for (idx_utf8, idx_utf16) in map {
97+
assert_eq!(converter.get(idx_utf8), idx_utf16);
98+
}
99+
}
100+
}

packages/@tailwindcss-upgrade/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
"postcss-import": "^16.1.0",
3939
"postcss-selector-parser": "^6.1.2",
4040
"prettier": "^3.3.3",
41-
"string-byte-slice": "^3.0.0",
4241
"tailwindcss": "workspace:^",
4342
"tree-sitter": "^0.21.1",
4443
"tree-sitter-typescript": "^0.23.0"

packages/@tailwindcss-upgrade/src/template/candidates.test.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
22
import { describe, expect, test } from 'vitest'
3-
import { extractRawCandidates, printCandidate, replaceCandidateInContent } from './candidates'
3+
import { extractRawCandidates, printCandidate } from './candidates'
4+
import { spliceChangesIntoString } from './splice-changes-into-string'
45

56
let html = String.raw
67

@@ -66,13 +67,20 @@ test('replaces the right positions for a candidate', async () => {
6667
({ rawCandidate }) => designSystem.parseCandidate(rawCandidate).length > 0,
6768
)!
6869

69-
expect(replaceCandidateInContent(content, 'flex', candidate.start, candidate.end))
70-
.toMatchInlineSnapshot(`
70+
let migrated = spliceChangesIntoString(content, [
71+
{
72+
start: candidate.start,
73+
end: candidate.end,
74+
replacement: 'flex',
75+
},
76+
])
77+
78+
expect(migrated).toMatchInlineSnapshot(`
79+
"
80+
<h1>🤠👋</h1>
81+
<div class="flex" />
7182
"
72-
<h1>🤠👋</h1>
73-
<div class="flex" />
74-
"
75-
`)
83+
`)
7684
})
7785

7886
const candidates = [

packages/@tailwindcss-upgrade/src/template/candidates.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Scanner } from '@tailwindcss/oxide'
2-
import stringByteSlice from 'string-byte-slice'
32
import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
43
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
54

@@ -139,12 +138,3 @@ function escapeArbitrary(input: string) {
139138
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
140139
.replaceAll(' ', '_') // Replace spaces with underscores
141140
}
142-
143-
export function replaceCandidateInContent(
144-
content: string,
145-
replacement: string,
146-
startByte: number,
147-
endByte: number,
148-
) {
149-
return stringByteSlice(content, 0, startByte) + replacement + stringByteSlice(content, endByte)
150-
}

packages/@tailwindcss-upgrade/src/template/migrate.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import fs from 'node:fs/promises'
22
import path, { extname } from 'node:path'
33
import type { Config } from 'tailwindcss'
44
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
5-
import { extractRawCandidates, replaceCandidateInContent } from './candidates'
5+
import { extractRawCandidates } from './candidates'
66
import { arbitraryValueToBareValue } from './codemods/arbitrary-value-to-bare-value'
77
import { automaticVarInjection } from './codemods/automatic-var-injection'
88
import { bgGradient } from './codemods/bg-gradient'
99
import { important } from './codemods/important'
1010
import { prefix } from './codemods/prefix'
1111
import { simpleLegacyClasses } from './codemods/simple-legacy-classes'
1212
import { variantOrder } from './codemods/variant-order'
13+
import { spliceChangesIntoString, type StringChange } from './splice-changes-into-string'
1314

1415
export type Migration = (
1516
designSystem: DesignSystem,
@@ -46,19 +47,23 @@ export default async function migrateContents(
4647
): Promise<string> {
4748
let candidates = await extractRawCandidates(contents, extension)
4849

49-
// Sort candidates by starting position desc
50-
candidates.sort((a, z) => z.start - a.start)
50+
let changes: StringChange[] = []
5151

52-
let output = contents
5352
for (let { rawCandidate, start, end } of candidates) {
5453
let migratedCandidate = migrateCandidate(designSystem, userConfig, rawCandidate)
5554

56-
if (migratedCandidate !== rawCandidate) {
57-
output = replaceCandidateInContent(output, migratedCandidate, start, end)
55+
if (migratedCandidate === rawCandidate) {
56+
continue
5857
}
58+
59+
changes.push({
60+
start,
61+
end,
62+
replacement: migratedCandidate,
63+
})
5964
}
6065

61-
return output
66+
return spliceChangesIntoString(contents, changes)
6267
}
6368

6469
export async function migrate(designSystem: DesignSystem, userConfig: Config, file: string) {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export interface StringChange {
2+
start: number
3+
end: number
4+
replacement: string
5+
}
6+
7+
/**
8+
* Apply the changes to the string such that a change in the length
9+
* of the string does not break the indexes of the subsequent changes.
10+
*/
11+
export function spliceChangesIntoString(str: string, changes: StringChange[]) {
12+
// If there are no changes, return the original string
13+
if (!changes[0]) return str
14+
15+
// Sort all changes in order to make it easier to apply them
16+
changes.sort((a, b) => {
17+
return a.end - b.end || a.start - b.start
18+
})
19+
20+
// Append original string between each chunk, and then the chunk itself
21+
// This is sort of a String Builder pattern, thus creating less memory pressure
22+
let result = ''
23+
24+
let previous = changes[0]
25+
26+
result += str.slice(0, previous.start)
27+
result += previous.replacement
28+
29+
for (let i = 1; i < changes.length; ++i) {
30+
let change = changes[i]
31+
32+
result += str.slice(previous.end, change.start)
33+
result += change.replacement
34+
35+
previous = change
36+
}
37+
38+
// Add leftover string from last chunk to end
39+
result += str.slice(previous.end)
40+
41+
return result
42+
}

0 commit comments

Comments
 (0)