Skip to content

Commit f8aba80

Browse files
authored
fix(stream): align ShikiStreamTokenizer offsets and CRLF handling with codeToTokens (#1335)
1 parent f07d3f9 commit f8aba80

2 files changed

Lines changed: 127 additions & 3 deletions

File tree

‎packages/stream/src/tokenizer.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { GrammarState, ThemedToken } from '@shikijs/core'
22
import type { ShikiStreamTokenizerEnqueueResult, ShikiStreamTokenizerOptions } from './types'
3+
import { splitLines } from '@shikijs/core'
34

45
export class ShikiStreamTokenizer {
56
public readonly options: ShikiStreamTokenizerOptions
@@ -8,6 +9,10 @@ export class ShikiStreamTokenizer {
89
public tokensUnstable: ThemedToken[] = []
910

1011
public lastUnstableCodeChunk: string = ''
12+
/**
13+
* Offset of `lastUnstableCodeChunk` in the whole streamed code
14+
*/
15+
public lastUnstableCodeOffset: number = 0
1116
public lastStableGrammarState: GrammarState | undefined
1217

1318
constructor(
@@ -20,22 +25,26 @@ export class ShikiStreamTokenizer {
2025
* Enqueue a chunk of code to the buffer.
2126
*/
2227
async enqueue(chunk: string): Promise<ShikiStreamTokenizerEnqueueResult> {
23-
const chunkLines = (this.lastUnstableCodeChunk + chunk).split('\n')
28+
const offset = this.lastUnstableCodeOffset
29+
const chunkLines = splitLines(this.lastUnstableCodeChunk + chunk)
2430

2531
const stable: ThemedToken[] = []
2632
let unstable: ThemedToken[] = []
2733
const recall = this.tokensUnstable.length
2834

29-
chunkLines.forEach((line, i) => {
35+
chunkLines.forEach(([line, lineOffset], i) => {
3036
const isLastLine = i === chunkLines.length - 1
3137

3238
const result = this.options.highlighter.codeToTokens(line, {
3339
...this.options,
3440
grammarState: this.lastStableGrammarState,
3541
})
3642
const tokens = result.tokens[0] // only one line
43+
for (const token of tokens)
44+
token.offset += offset + lineOffset
45+
// the `\n` is the last character before the next line, after any `\r`
3746
if (!isLastLine)
38-
tokens.push({ content: '\n', offset: 0 })
47+
tokens.push({ content: '\n', offset: offset + chunkLines[i + 1][1] - 1 })
3948

4049
if (!isLastLine) {
4150
this.lastStableGrammarState = result.grammarState
@@ -44,6 +53,7 @@ export class ShikiStreamTokenizer {
4453
else {
4554
unstable = tokens
4655
this.lastUnstableCodeChunk = line
56+
this.lastUnstableCodeOffset = offset + lineOffset
4757
}
4858
})
4959

@@ -61,6 +71,7 @@ export class ShikiStreamTokenizer {
6171
const stable = this.tokensUnstable
6272
this.tokensUnstable = []
6373
this.lastUnstableCodeChunk = ''
74+
this.lastUnstableCodeOffset = 0
6475
this.lastStableGrammarState = undefined
6576
return {
6677
stable,
@@ -71,6 +82,7 @@ export class ShikiStreamTokenizer {
7182
this.tokensStable = []
7283
this.tokensUnstable = []
7384
this.lastUnstableCodeChunk = ''
85+
this.lastUnstableCodeOffset = 0
7486
this.lastStableGrammarState = undefined
7587
}
7688

@@ -79,6 +91,7 @@ export class ShikiStreamTokenizer {
7991
this.options,
8092
)
8193
clone.lastUnstableCodeChunk = this.lastUnstableCodeChunk
94+
clone.lastUnstableCodeOffset = this.lastUnstableCodeOffset
8295
clone.tokensUnstable = this.tokensUnstable
8396
clone.tokensStable = this.tokensStable
8497
clone.lastStableGrammarState = this.lastStableGrammarState

‎packages/stream/test/tokenizer.test.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ThemedToken } from 'shiki'
12
import type { ShikiStreamTokenizerOptions } from '../src'
23
import { createHighlighter } from 'shiki'
34
import { expect, it } from 'vitest'
@@ -64,3 +65,113 @@ it('exported', async () => {
6465

6566
expect.soft(merged).toBe(onepass)
6667
})
68+
69+
const tokensOptions = {
70+
lang: 'vue',
71+
themes: {
72+
dark: 'vitesse-dark',
73+
light: 'vitesse-light',
74+
},
75+
} as const
76+
77+
async function tokenizeInChunks(options: ShikiStreamTokenizerOptions, code: string, size: number): Promise<ThemedToken[]> {
78+
const streamer = new ShikiStreamTokenizer(options)
79+
for (let i = 0; i < code.length; i += size)
80+
await streamer.enqueue(code.slice(i, i + size))
81+
return [...streamer.tokensStable, ...streamer.close().stable]
82+
}
83+
84+
// The streamed tokens, apart from the inserted `\n` ones, match the one-pass tokens,
85+
// every token's offset points at its content in the code, and each inserted `\n` sits at its own LF
86+
function expectSameTokens(options: ShikiStreamTokenizerOptions, code: string, tokens: ThemedToken[]): void {
87+
expect(tokens.filter(token => token.content !== '\n')).toEqual(options.highlighter.codeToTokens(code, options).tokens.flat())
88+
for (const token of tokens)
89+
expect(code.slice(token.offset, token.offset + token.content.length)).toBe(token.content)
90+
expect(tokens.filter(token => token.content === '\n').map(token => token.offset))
91+
.toEqual([...code.matchAll(/\n/g)].map(match => match.index))
92+
}
93+
94+
it('handles CRLF line endings like codeToTokens', async () => {
95+
const highlighter = await createHighlighter({
96+
langs: ['vue'],
97+
themes: ['vitesse-dark', 'vitesse-light'],
98+
})
99+
const code = fixture.trimEnd().replaceAll('\n', '\r\n')
100+
const onepass = tokensToHtml(
101+
highlighter.codeToTokens(code, tokensOptions)
102+
.tokens
103+
.flatMap((i, idx) => idx === 0 ? i : [{ content: '\n', offset: 0 }, ...i]),
104+
)
105+
106+
// A chunk size of 1 splits every `\r\n` across two chunks
107+
for (const size of [1, 5, code.length]) {
108+
const tokens = await tokenizeInChunks({ highlighter, ...tokensOptions }, code, size)
109+
expect(tokens.filter(token => token.content.includes('\r'))).toEqual([])
110+
expect(tokensToHtml(tokens)).toBe(onepass)
111+
}
112+
})
113+
114+
it('reports token offsets relative to the whole code', async () => {
115+
const highlighter = await createHighlighter({
116+
langs: ['vue'],
117+
themes: ['vitesse-dark', 'vitesse-light'],
118+
})
119+
120+
const options: ShikiStreamTokenizerOptions = { highlighter, ...tokensOptions }
121+
122+
for (const code of [fixture.trimEnd(), fixture.trimEnd().replaceAll('\n', '\r\n')]) {
123+
for (const size of [1, 5, code.length])
124+
expectSameTokens(options, code, await tokenizeInChunks(options, code, size))
125+
}
126+
})
127+
128+
it('reports whole-code offsets after every chunk', async () => {
129+
const highlighter = await createHighlighter({
130+
langs: ['ts'],
131+
themes: ['vitesse-light'],
132+
})
133+
const options: ShikiStreamTokenizerOptions = { highlighter, lang: 'ts', theme: 'vitesse-light' }
134+
const streamer = new ShikiStreamTokenizer(options)
135+
136+
// `\r`, an empty chunk, `\n` and the two halves of the emoji arrive separately
137+
const chunks = ['const a = 1', '\r', '', '\n', 'const b = \'\uD83D', '\uDE00\'\r\n', 'b']
138+
let code = ''
139+
const received: ThemedToken[] = []
140+
for (const chunk of chunks) {
141+
code += chunk
142+
const { stable, unstable } = await streamer.enqueue(chunk)
143+
received.push(...stable)
144+
expectSameTokens(options, code, [...received, ...unstable])
145+
}
146+
expectSameTokens(options, code, [...received, ...streamer.close().stable])
147+
})
148+
149+
it('continues offsets in a clone', async () => {
150+
const highlighter = await createHighlighter({
151+
langs: ['ts'],
152+
themes: ['vitesse-light'],
153+
})
154+
const options: ShikiStreamTokenizerOptions = { highlighter, lang: 'ts', theme: 'vitesse-light' }
155+
const streamer = new ShikiStreamTokenizer(options)
156+
await streamer.enqueue('const a = 1\r\nconst b')
157+
158+
const clone = streamer.clone()
159+
await clone.enqueue(' = 2\nc')
160+
expectSameTokens(options, 'const a = 1\r\nconst b = 2\nc', [...clone.tokensStable, ...clone.close().stable])
161+
})
162+
163+
it('restarts offsets after clear() and close()', async () => {
164+
const highlighter = await createHighlighter({
165+
langs: ['ts'],
166+
themes: ['vitesse-light'],
167+
})
168+
const options: ShikiStreamTokenizerOptions = { highlighter, lang: 'ts', theme: 'vitesse-light' }
169+
170+
for (const reset of ['clear', 'close'] as const) {
171+
const streamer = new ShikiStreamTokenizer(options)
172+
await streamer.enqueue('const a = 1\nconst b')
173+
streamer[reset]()
174+
const { stable, unstable } = await streamer.enqueue('let c\nd')
175+
expectSameTokens(options, 'let c\nd', [...stable, ...unstable])
176+
}
177+
})

0 commit comments

Comments
 (0)