【免费下载链接】pierre
pierre’s open source code
LiveTokenizer是@pierre/highlights包中面向可编辑文档的增量词法分析器:它在一个隔离的 WebAssembly 实例中持有文档,编辑后仅对受影响行及后续行重新分词,直到词法器状态与保留状态收敛,从而把全文档重分词的 O(n) 开销摊薄到每次编辑的局部范围。本文基于 skills/highlights/references/live.md 展开,结合 packages/highlights/lib/live.ts 源码与 packages/highlights/test/live_tokenizer_test.ts 测试,完整讲解编辑应用、视口优先级调度、文档/生命周期 API 与打包记录(packed records)等核心能力,读完即可在自己的渲染器或编辑器中接入增量高亮。
@pierre/highlights的定位是"内置 WebAssembly 词法分析器的独立语法高亮库":一次性输出 HTML 用codeToHtml,自定义渲染用codeToTokens,追加式输入用StreamTokenizer,而可编辑文档正是LiveTokenizer的职责(见 skills/highlights/SKILL.md 中的参考选择表)。安装方式:
pnpm add @pierre/highlights导入后条件导出会自动初始化 WebAssembly(Node.js、浏览器、Cloudflare Workers 均支持),普通用法无需手动init()或语言注册。
概览:LiveTokenizer 的增量模型
LiveTokenizer拥有一份文档与一个隔离的 Wasm 实例(packages/highlights/lib/live.ts中HighlightsHighlighter封装,构造时通过liveStage/liveInitDoc初始化)。它的核心增量策略是:
- 编辑后仅重新分词变更行及其后的行,直到词法器状态与保留状态收敛(类 Doc 注释:
Re-tokenization starts at each changed region and stops when its outgoing lexer state matches the previous state); - 构造函数选项继承 tokenization options(
lang、theme/themes、cssVariablePrefix、tokenizeMaxLineLength等),并额外支持code(默认空字符串)、renderRange、onDeferTokenize三个增量专属选项。
增量专属构造选项
| 选项 | 含义 |
|---|---|
code | 初始文档文本,默认空字符串 |
renderRange | 半开区间[startLine, endLine)(基于后编辑行号),请求一个有预算的初始视口 |
onDeferTokenize | 接收renderRange之外完成的行(以Map<number, HighlightedToken[]>传递);构造时带renderRange时,第一次投递可能发生在构造函数返回之前 |
三个选项都在 packages/highlights/lib/live.ts 的LiveTokenizerOptions(CodeToTokensOptions的扩展)中有定义。renderRange需满足0 <= startLine <= endLine,非法值抛TypeError。
运行时能力
- 条件导出自动初始化 Wasm:导入
@pierre/highlights即可同步高亮,无需init(); - 性能核心:每次编辑只处理脏区域及其状态传播链,视口优先、后台分片收敛;
- 兼容性:token 对象与 Shiki 兼容(
ThemedToken),但词法边界与语法分类可能不同。
应用编辑:applyEdits 的使用与语义
applyEdits(edits, options?)接收一个编辑批次,返回LiveTokenizerUpdate。编辑坐标使用批处理前文档中的零基行号与 UTF-16 字符位置,end位置不包含(end-exclusive),即[start, end)半开区间。
import { LiveTokenizer } from '@pierre/highlights'; import { pierreDark } from '@pierre/highlights/themes'; const live = new LiveTokenizer({ code: 'const answer = 41;\nconsole.log(answer);', lang: 'ts', theme: pierreDark, }); try { const update = live.applyEdits([ { range: { start: { line: 0, character: 15 }, end: { line: 0, character: 17 }, }, newText: '42', }, ]); console.log(live.getLineText(0)); // const answer = 42; for (const change of update.lineChanges) { for (let line = change.newStartLine; line < change.newEndLine; line++) { console.log(line, live.getLineTokens(line)); } } } finally { live.dispose(); }要点(源码#validate逻辑可见):
- 坐标语义:
{line, character}均为非负整数,character可等于行长度;位置越界或start在end之后抛RangeError。 - 重叠拒绝:整个批次在任何变更发生前完成验证(
#validate先于#stageEdits/liveApplyEdits),重叠区间抛RangeError;两个相同位置的插入无定义顺序,同样拒绝。 - 合并与去重:共享同一行的编辑合并为一次 splice,保留它们之间未触碰的文本;字节级相同的替换(no-op)被剔除。排序后的批处理按升序应用。
- 行终止符:编辑可拆分/合并
\r\n、\n或孤立的\r;词法器看到每个换行均为\n(lexNormalized测试辅助函数印证)。
update 的字段与渲染消费
无renderRange时更新同步完成,update.lines为空。渲染器应使用lineChanges与getLineTokens()刷新视图:
LiveLineChange把旧行区间[oldStartLine, oldEndLine)映射到新行区间[newStartLine, newEndLine)(均为半开);对账视图时也要删除已删除的行(如按oldEndLine - oldStartLine移除旧行 DOM/虚拟行)。LiveTokenizerUpdate还包含revision、previousLineCount、lineCount,供渲染器校验与增量布局。
行 token 读取:getLineTokens
getLineTokens(line)返回{ tokens, bracketIgnoredRanges }:
tokens为ThemedToken[],token 偏移相对于该行(UTF-16 列);bracketIgnoredRanges为端排他的 UTF-16 列对[start, end),标记字符串、注释、正则表达式区域,括号匹配应跳过这些范围(测试live_tokenizer_test.ts中assert.deepEqual(live.getLineTokens(3).bracketIgnoredRanges, [[0, 6]])等用例覆盖)。
tokenizeMaxLineLength配置下,达到上限的行折叠为单个无主题 token(原始 records 仍保持精确)。
视口优先:renderRange 与 onDeferTokenize
renderRange: [startLine, endLine]使用后编辑行号,排除endLine。每次需要优先视口的更新都传入它。视口之前的脏行也必须处理以建立词法器状态;剩余工作在后台分片中继续。
import { LiveTokenizer } from '@pierre/highlights'; import { pierreDark } from '@pierre/highlights/themes'; const live = new LiveTokenizer({ code: 'const answer = 41;\nconsole.log(answer);', lang: 'ts', theme: pierreDark, renderRange: [0, 1], onDeferTokenize(lines) { // This callback can run before the constructor returns. for (const [line, tokens] of lines) console.log(line, tokens); }, }); try { console.log(live.getLineTokens(0)); // Read the initial viewport after construction. const update = live.applyEdits( [ { range: { start: { line: 0, character: 15 }, end: { line: 0, character: 17 }, }, newText: '42', }, ], { renderRange: [0, 1] } ); for (const [line, tokens] of update.lines) console.log(line, tokens); live.flush(); // Finish pending work before reading the complete document's tokens. } finally { live.dispose(); }关键语义:
update.lines只含范围内已重分词的行,不是整个视口;视口内未完成的行稍后经onDeferTokenize到达(#workSlice中按renderRange分流到lines或deferred)。onDeferTokenize可能在构造或更新过程中同步执行(测试注释明确:can run before the constructor returns)。因此回调必须独立于接收新实例的变量,且不要在回调内调用applyEdits()或reset()——在同步回调里调用会抛错(#checkNotUpdating抛出applyEdits and reset cannot be called from onDeferTokenize during an update; use pause, flush, or the reads instead)。- 调度机制:后台分片通过
MessageChannel排队(无该 API 的运行时回退到setTimeout),避免嵌套定时器钳制(timer clamping),每片预算 1ms(performance.now() + 1),片间让出事件循环。 - token 形态:两张映射均含
HighlightedToken[]—— 三元组[character, foregroundColor, text],颜色来自第一个解析的主题。需要字体样式或多主题htmlStyle映射时,请用getLineTokens()(其返回完整ThemedToken,带color/fontStyle/htmlStyle字段)。 - 不完整状态:
pendingTokenization为 true 时,未到达的行可能保留旧 token,新行可能只有前景色 token。需要完全最新的 token 时,先flush()。 - 调度进一步编辑:在当前更新之后再调度下一次编辑(同一更新的同步回调内不允许变更)。
文档与生命周期 API
| 成员 | 用途 |
|---|---|
revision,lineCount,pendingTokenization | 读取文档修订号、行数与待处理工作量 |
applyEdits(edits, options?) | 应用一批编辑,返回LiveTokenizerUpdate |
reset(code, options?) | 替换全部文本,返回LiveTokenizerUpdate |
getLineLength(line),getLineText(line) | 读取某行的 UTF-16 长度或文本(不含终止符) |
getText() | 读取全部文本,保留 LF、CRLF 与孤立 CR |
getLineTokens(line) | 读取主题化 token 与括号忽略范围 |
getLineRecords(line) | 读取打包的原始 token 记录 |
flush(endLine?) | 在排他行边界前完成待处理工作;省略则完成全部 |
pause(),resume() | 挂起或恢复后台分词 |
dispose() | 释放实例并取消延迟工作;可重复调用 |
行为细节(源码与测试印证):
reset(code, options?)在全新 Wasm 实例中重建文档并换入(#createStaged),保留语言与主题选项;要改变语言/主题必须重建 tokenizer。返回的lineChanges为整文档替换[0, oldLineCount) → [0, newLineCount)。flush()、reset()、applyEdits()(即使空批次)都会恢复被挂起的工作;applyEdits空批次时直接resume()并返回空lineChanges。pause()只挂起后台分片(不丢弃待处理工作),挂起期间读取未到达的行仍返回编辑前 token;resume()重新调度剩余部分。flush(endLine?)可带排他边界(如flush(2)只完成前两行),非法参数抛错(测试assert.throws(() => live.flush(-1))、assert.throws(() => live.flush(1.5)))。dispose()后,其他方法与状态 getter 均抛错(tokenizer is disposed)。- 生命周期建议:为文档的整个生命周期保留一个 tokenizer,编辑器卸载/关闭时
dispose()。
revision 与 pendingTokenization
revision为单调递增的修订号,每次成功的变更批次 +1;lineCount为文档行数(尾部终止符会多算一行)。pendingTokenization反映是否存在待处理的扫描或 token 物化(#materializing未清空或 Wasm 内部统计非零)。测试assertLineFedParity/assertMatchesFresh用全新全量分词对照验证增量结果与全量结果逐行一致。
打包记录:getLineRecords 与 tokenNames
getLineRecords(line)返回LiveTokenRecords:
- 字段:
revision(读取时的修订号)、format('packed24' | 'wide32')、data: Uint32Array。 - 起始位置是隐式的:第一个 token 的 start 为 0,之后每个 token 的 start 为前一个 token 的 end;end 为该行内 UTF-16 列。
| 格式 | 编码 |
|---|---|
packed24 | 每 token 一个字:tokenId = word >>> 24,end = word & 0xffffff |
wide32 | [endUtf16, tokenId]成对出现,用于超出 24 位范围(超长 end)的行 |
从 packages/highlights/lib/live.ts 可确认:getLineRecords返回的是Wasm 内存上的零拷贝视图(new Uint32Array(hl.memory.buffer, ptr, n)),因此:
- 视图在下一次成功的编辑、reset、延迟分词分片、dispose后被失效;
- 需要跨这些操作保留数据时,必须
records.data.slice()复制; - 延迟工作可在不改变文档修订号的情况下使数据失效(后台分片仍在重新分词)。
tokenNames从@pierre/highlights导入(export const tokenNames = tokenTypes,即$Token名称的槽位顺序表),用 token id 索引可映射到语法作用域名:
import { tokenNames } from '@pierre/highlights'; // 例:把 packed24 记录展开为 [start, end, scopeName] for (let i = 0; i < data.length; i++) { const word = data[i]; const id = word >>> 24; const end = word & 0xffffff; spans.push([start, end, tokenNames[id]]); start = end; }测试live_tokenizer_test.ts中assert.ok(id < tokenNames.length)即校验 id 与作用域名表的对应关系。getLineRecords适合高性能自定义渲染(如虚拟滚动只读行、按 id 缓存样式),而需要字符串文本、括号忽略范围或多主题样式时用getLineTokens。
根入口导出的增量类型
根入口导出以下增量相关类型(packages/highlights/lib/index.ts可见):
HighlightedToken、LiveLineChange、LivePosition、LiveTextEdit、LiveTokenizerOptions、LiveTokenizerUpdate、LiveTokenRecords、LiveUpdateOptions,以及值LiveTokenizer与tokenNames。
LivePosition/LiveTextEdit是公开的位置与编辑类型:LivePosition = { line, character },LiveTextEdit = { range: { start, end }, newText };编辑与 token 偏移均按 UTF-16 计。LiveUpdateOptions携带可选的renderRange。- 在
@pierre/diffs编辑器组件中,增量高亮由该库的 Worker 侧 live 模块承载(见 packages/diffs/src/worker),此处不再展开。
集成实践与陷阱小结
- 渲染器刷新:无
renderRange时用update.lineChanges定位旧→新行映射,逐行getLineTokens()重绘;记得删除旧行。 - 视口优先:编辑器滚动/编辑时传
{ renderRange: [start, end] };首次构造即可指定renderRange+onDeferTokenize让首屏优先渲染。 - 回调独立性:
onDeferTokenize可能同步触发(构造期间或更新期间),不要在其中调用applyEdits/reset,也不要依赖接收实例的变量尚未赋值。 - 完整读取前 flush:需要全文档 token 或跨操作持有 records 前,调用
flush()(可带endLine只收敛到某行)并视需要data.slice()。 - 生命周期:文档存活期间复用同一实例,
dispose()释放 Wasm 内存;reset()换文本但语言/主题不变,换语言/主题则重建实例。 - 挂起协作:后台工作可用
pause()/resume()让出 CPU;flush/reset/applyEdits会自动恢复。 - 性能取舍:
getLineTokens输出结构化主题化 token(含多主题htmlStyle),getLineRecords输出零拷贝打包记录(packed24/wide32),追求极致渲染性能时优先后者。
参考资料:本指南对应 skills/highlights/references/live.md;token 选项与语言别名见 skills/highlights/references/rendering.md;主题与 CSS 变量见 skills/highlights/references/themes.md;追加式流式输入见 skills/highlights/references/streaming.md。核心实现位于 packages/highlights/lib/live.ts,行为验证见 packages/highlights/test/live_tokenizer_test.ts。
【免费下载链接】pierre
pierre’s open source code
相关推荐
Tree-sitter 终极指南:代码编辑器中的增量解析利器
Tree sitter 终极指南:代码编辑器中的增量解析利器 Tree sitter 是一个革命性的增量解析系统,专为现代编程工具设计。它能够快速构建源代码的具
开发工具DBX 数据导出与导入实操指南:从单表 CSV 到跨库迁移
DBX 数据导出与导入实操指南:从单表 CSV 到跨库迁移 如果你被要求"把这份数据拷一份",或者要把测试库的东西整个搬到预发库,绕不开 DBX 数据导出 和
数据库客户端数据库桌面应用CLI后端MCP 服务AI 应用Web代码编辑增强工具Monaco Editor中文文档解析
Web代码编辑增强工具Monaco Editor中文文档解析 作为Visual Studio Code的核心编辑器组件,Monaco Editor(简称"Mon
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考