Skip to content

Commit 9844623

Browse files
Improve performance of spliceChangesIntoString (#312)
* Improve performance of spliceChangesIntoString * Add test * Add benchmark * Optimize implementation a bit * Tweak benchmarks * Update changelog --------- Co-authored-by: ABuffSeagull <[email protected]>
1 parent 0368ffb commit 9844623

File tree

4 files changed

+113
-9
lines changed

4 files changed

+113
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
- Improved performance with large Svelte, Liquid, and Angular files ([#312](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/312))
1111

1212
## [0.6.6] - 2024-08-09
1313

src/utils.bench.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { bench, describe } from 'vitest'
2+
import type { StringChange } from './types'
3+
import { spliceChangesIntoString } from './utils'
4+
5+
describe('spliceChangesIntoString', () => {
6+
// 44 bytes
7+
let strTemplate = 'the quick brown fox jumps over the lazy dog '
8+
let changesTemplate: StringChange[] = [
9+
{ start: 10, end: 15, before: 'brown', after: 'purple' },
10+
{ start: 4, end: 9, before: 'quick', after: 'slow' },
11+
]
12+
13+
function buildFixture(repeatCount: number, changeCount: number) {
14+
// A large set of changes across random places in the string
15+
let indxes = new Set(
16+
Array.from({ length: changeCount }, (_, i) =>
17+
Math.ceil(Math.random() * repeatCount),
18+
),
19+
)
20+
21+
let changes: StringChange[] = Array.from(indxes).flatMap((idx) => {
22+
return changesTemplate.map((change) => ({
23+
start: change.start + strTemplate.length * idx,
24+
end: change.end + strTemplate.length * idx,
25+
before: change.before,
26+
after: change.after,
27+
}))
28+
})
29+
30+
return [strTemplate.repeat(repeatCount), changes] as const
31+
}
32+
33+
let [strS, changesS] = buildFixture(5, 2)
34+
bench('small string', () => {
35+
spliceChangesIntoString(strS, changesS)
36+
})
37+
38+
let [strM, changesM] = buildFixture(100, 5)
39+
bench('medium string', () => {
40+
spliceChangesIntoString(strM, changesM)
41+
})
42+
43+
let [strL, changesL] = buildFixture(1_000, 50)
44+
bench('large string', () => {
45+
spliceChangesIntoString(strL, changesL)
46+
})
47+
48+
let [strXL, changesXL] = buildFixture(100_000, 500)
49+
bench('extra large string', () => {
50+
spliceChangesIntoString(strXL, changesXL)
51+
})
52+
53+
let [strXL2, changesXL2] = buildFixture(100_000, 5_000)
54+
bench('extra large string (5k changes)', () => {
55+
spliceChangesIntoString(strXL2, changesXL2)
56+
})
57+
})

src/utils.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { bench, describe, test } from 'vitest'
2+
import type { StringChange } from './types'
3+
import { spliceChangesIntoString } from './utils'
4+
5+
describe('spliceChangesIntoString', () => {
6+
test('can apply changes to a string', ({ expect }) => {
7+
let str = 'the quick brown fox jumps over the lazy dog'
8+
let changes: StringChange[] = [
9+
//
10+
{ start: 10, end: 15, before: 'brown', after: 'purple' },
11+
]
12+
13+
expect(spliceChangesIntoString(str, changes)).toBe(
14+
'the quick purple fox jumps over the lazy dog',
15+
)
16+
})
17+
18+
test('changes are applied in order', ({ expect }) => {
19+
let str = 'the quick brown fox jumps over the lazy dog'
20+
let changes: StringChange[] = [
21+
//
22+
{ start: 10, end: 15, before: 'brown', after: 'purple' },
23+
{ start: 4, end: 9, before: 'quick', after: 'slow' },
24+
]
25+
26+
expect(spliceChangesIntoString(str, changes)).toBe(
27+
'the slow purple fox jumps over the lazy dog',
28+
)
29+
})
30+
})

src/utils.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,34 @@ export function visit<T extends {}, Meta extends Record<string, unknown>>(
106106
* of the string does not break the indexes of the subsequent changes.
107107
*/
108108
export function spliceChangesIntoString(str: string, changes: StringChange[]) {
109-
// Sort all changes in reverse order so we apply them from the end of the string
110-
// to the beginning. This way, the indexes for the changes after the current one
111-
// will still be correct after applying the current one.
109+
// If there are no changes, return the original string
110+
if (!changes[0]) return str
111+
112+
// Sort all changes in order to make it easier to apply them
112113
changes.sort((a, b) => {
113-
return b.end - a.end || b.start - a.start
114+
return a.end - b.end || a.start - b.start
114115
})
115116

116-
// Splice in each change to the string
117-
for (let change of changes) {
118-
str = str.slice(0, change.start) + change.after + str.slice(change.end)
117+
// Append original string between each chunk, and then the chunk itself
118+
// This is sort of a String Builder pattern, thus creating less memory pressure
119+
let result = ''
120+
121+
let previous = changes[0]
122+
123+
result += str.slice(0, previous.start)
124+
result += previous.after
125+
126+
for (let i = 1; i < changes.length; ++i) {
127+
let change = changes[i]
128+
129+
result += str.slice(previous.end, change.start)
130+
result += change.after
131+
132+
previous = change
119133
}
120134

121-
return str
135+
// Add leftover string from last chunk to end
136+
result += str.slice(previous.end)
137+
138+
return result
122139
}

0 commit comments

Comments
 (0)