Skip to content

Commit 2ef5e3d

Browse files
issacgergesantfu
andauthored
perf(engine-js): reuse each regex's last search while tokenizing a line (#1314)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 48cd2cc commit 2ef5e3d

8 files changed

Lines changed: 317 additions & 51 deletions

File tree

‎docs/references/engine-js-compat.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
Compatibility reference of all built-in grammars with the [JavaScript RegExp engine](/guide/regex-engines#javascript-regexp-engine).
44

5-
> Generated on Friday, July 31, 2026
5+
> Generated on Friday, September 11, 2026
66
>
7-
> Version `4.3.1`
7+
> Version `4.4.3`
88
>
99
> Runtime: Node.js v24.16.0
1010
@@ -239,7 +239,7 @@ In some edge cases, it's not guaranteed that the highlighting will be 100% the s
239239
| twig | ✅ OK | 2445 | - | |
240240
| typescript | ✅ OK | 362 | - | |
241241
| typespec | ✅ OK | 73 | - | |
242-
| typst | ✅ OK | 78 | - | |
242+
| typst | ✅ OK | 5699 | - | |
243243
| v | ✅ OK | 77 | - | |
244244
| vala | ✅ OK | 20 | - | |
245245
| vb | ✅ OK | 34 | - | |

‎packages/engine-javascript/src/engine-compile.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ToRegExpOptions } from 'oniguruma-to-es'
33
import type { JavaScriptRegexScannerOptions } from './scanner'
44
import { toRegExp } from 'oniguruma-to-es'
55
import { JavaScriptScanner } from './scanner'
6+
import { createJavaScriptEngineString } from './string'
67

78
export interface JavaScriptRegexEngineOptions extends JavaScriptRegexScannerOptions {
89
/**
@@ -73,10 +74,6 @@ export function createJavaScriptRegexEngine(options: JavaScriptRegexEngineOption
7374
createScanner(patterns) {
7475
return new JavaScriptScanner(patterns, _options)
7576
},
76-
createString(s: string) {
77-
return {
78-
content: s,
79-
}
80-
},
77+
createString: createJavaScriptEngineString,
8178
}
8279
}

‎packages/engine-javascript/src/engine-raw.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { RegexEngine } from '@shikijs/types'
22
import type { JavaScriptRegexScannerOptions } from './scanner'
33
import { JavaScriptScanner } from './scanner'
4+
import { createJavaScriptEngineString } from './string'
45

56
/**
67
* Raw JavaScript regex engine that only supports precompiled grammars.
@@ -21,10 +22,6 @@ export function createJavaScriptRawEngine(): RegexEngine {
2122
createScanner(patterns) {
2223
return new JavaScriptScanner(patterns, options)
2324
},
24-
createString(s: string) {
25-
return {
26-
content: s,
27-
}
28-
},
25+
createString: createJavaScriptEngineString,
2926
}
3027
}

‎packages/engine-javascript/src/scanner.ts‎

Lines changed: 85 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
RegexEngineString,
44
} from '@shikijs/types'
55
import type { IOnigMatch } from '@shikijs/vscode-textmate'
6+
import type { JavaScriptEngineString } from './string'
67

78
const MAX = 4294967295
89

@@ -27,9 +28,48 @@ export interface JavaScriptRegexScannerOptions {
2728
regexConstructor?: (pattern: string) => RegExp
2829
}
2930

31+
interface LastSearch {
32+
strId: number
33+
searchedFrom: number
34+
matchIndex: number // -1 for no match
35+
indices: RegExpIndicesArray | null
36+
}
37+
38+
// Sticky regexps and `EmulatedRegExp`s with a search strategy (how `oniguruma-to-es` emulates `\G`)
39+
// depend on the exact start position, so their searches can't be reused
40+
function isSearchCacheable(regexp: RegExp): boolean {
41+
return !regexp.sticky && !(regexp as { rawOptions?: { strategy?: string | null } }).rawOptions?.strategy
42+
}
43+
44+
function toResult(index: number, indices: RegExpIndicesArray): IOnigMatch {
45+
return {
46+
index,
47+
captureIndices: indices.map((indice) => {
48+
if (indice == null) {
49+
return {
50+
start: MAX,
51+
end: MAX,
52+
length: 0,
53+
}
54+
}
55+
return {
56+
start: indice[0],
57+
end: indice[1],
58+
length: indice[1] - indice[0],
59+
}
60+
}),
61+
}
62+
}
63+
3064
export class JavaScriptScanner implements PatternScanner {
3165
regexps: (RegExp | null)[]
3266

67+
/**
68+
* Last search of each regexp on the current string, reused the way `vscode-oniguruma` does.
69+
* `null` for regexps whose searches can't be reused.
70+
*/
71+
private lastSearches: (LastSearch | null)[]
72+
3373
constructor(
3474
public patterns: (string | RegExp)[],
3575
public options: JavaScriptRegexScannerOptions = {},
@@ -71,51 +111,66 @@ export class JavaScriptScanner implements PatternScanner {
71111
throw e
72112
}
73113
})
114+
115+
this.lastSearches = this.regexps.map(regexp => regexp && isSearchCacheable(regexp)
116+
? { strId: 0, searchedFrom: 0, matchIndex: -1, indices: null }
117+
: null,
118+
)
74119
}
75120

76121
findNextMatchSync(string: string | RegexEngineString, startPosition: number, _options: number): IOnigMatch | null {
77122
const str = typeof string === 'string'
78123
? string
79124
: string.content
80-
const pending: [index: number, match: RegExpExecArray, offset: number][] = []
125+
// No id (plain string or a string from another engine): never reuse
126+
const strId = typeof string === 'string'
127+
? 0
128+
: (string as Partial<JavaScriptEngineString>).id || 0
81129

82-
function toResult(index: number, match: RegExpExecArray, offset = 0): IOnigMatch {
83-
return {
84-
index,
85-
captureIndices: match.indices!.map((indice) => {
86-
if (indice == null) {
87-
return {
88-
start: MAX,
89-
end: MAX,
90-
length: 0,
91-
}
92-
}
93-
return {
94-
start: indice[0] + offset,
95-
end: indice[1] + offset,
96-
length: indice[1] - indice[0],
97-
}
98-
}),
99-
}
100-
}
130+
let bestIndex = -1
131+
let bestMatchIndex = 0
132+
let bestIndices: RegExpIndicesArray | null = null
101133

102134
for (let i = 0; i < this.regexps.length; i++) {
103135
const regexp = this.regexps[i]
104136
if (!regexp)
105137
continue
106138
try {
107-
regexp.lastIndex = startPosition
108-
const match = regexp.exec(str)
139+
const last = strId ? this.lastSearches[i] : null
140+
let matchIndex: number
141+
let indices: RegExpIndicesArray | null
142+
143+
// Still valid if it found nothing, or a match at or after the start position
144+
if (last && last.strId === strId && last.searchedFrom <= startPosition && (last.matchIndex === -1 || last.matchIndex >= startPosition)) {
145+
matchIndex = last.matchIndex
146+
indices = last.indices
147+
}
148+
else {
149+
regexp.lastIndex = startPosition
150+
const match = regexp.exec(str)
151+
matchIndex = match ? match.index : -1
152+
indices = match ? match.indices! : null
153+
if (last) {
154+
last.strId = strId
155+
last.searchedFrom = startPosition
156+
last.matchIndex = matchIndex
157+
last.indices = indices
158+
}
159+
}
109160

110-
if (!match)
161+
if (matchIndex === -1)
111162
continue
112163

113164
// If the match is at the start position, return it immediately
114-
if (match.index === startPosition) {
115-
return toResult(i, match, 0)
165+
if (matchIndex === startPosition) {
166+
return toResult(i, indices!)
167+
}
168+
// Otherwise, keep the closest one
169+
if (bestIndex === -1 || matchIndex < bestMatchIndex) {
170+
bestIndex = i
171+
bestMatchIndex = matchIndex
172+
bestIndices = indices
116173
}
117-
// Otherwise, store it for later
118-
pending.push([i, match, 0])
119174
}
120175
catch (e) {
121176
if (this.options.forgiving)
@@ -124,16 +179,8 @@ export class JavaScriptScanner implements PatternScanner {
124179
}
125180
}
126181

127-
// Find the closest match to the start position
128-
if (pending.length) {
129-
const minIndex = Math.min(...pending.map(m => m[1].index))
130-
for (const [i, match, offset] of pending) {
131-
if (match.index === minIndex) {
132-
return toResult(i, match, offset)
133-
}
134-
}
135-
}
136-
137-
return null
182+
return bestIndex === -1
183+
? null
184+
: toResult(bestIndex, bestIndices!)
138185
}
139186
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { RegexEngineString } from '@shikijs/types'
2+
3+
/**
4+
* String with an `id`, so scanners can reuse searches on it (like `OnigString`).
5+
*/
6+
export interface JavaScriptEngineString extends RegexEngineString {
7+
readonly id: number
8+
}
9+
10+
let lastStringId = 0
11+
12+
export function createJavaScriptEngineString(content: string): JavaScriptEngineString {
13+
return {
14+
content,
15+
id: ++lastStringId,
16+
}
17+
}

0 commit comments

Comments
 (0)