zh CN 源码位置追踪 - chiba233/yumeDSL GitHub Wiki
源码位置追踪
你的 DSL 源码是一整串文本,解析后变成了 token 树——但如果用户写错了,你怎么告诉他"第 3 行第 12 列有问题"? 这就是位置追踪干的事:给每个 token 打上它在源码里的精确坐标。
整体流程
源码文本
│
trackPositions: true
│
┌─────────┴──────────┐
▼ ▼
buildPositionTracker 解析器扫描
(扫一遍文本,建行偏移表) (正常解析流程)
│ │
▼ ▼
PositionTracker token 产出时
.resolve(offset) 查表 → 填 position
(在行表里二分查找)
│
▼
TextToken.position = {
start: { offset, line, column },
end: { offset, line, column }
}
关掉时(默认): 不建表、不算坐标、几乎零开销。 打开时: 建表扫一遍文本,之后每次查坐标走一次二分查找,都很快。
怎么开
传 trackPositions: true 给 parseRichText 或 parseStructural 就行。两者都支持:
parseRichText→ 在TextToken.position上拿到坐标parseStructural→ 在StructuralNode.position上拿到坐标
const tokens = parseRichText("hello $$bold(world)$$", {
handlers: {bold: {inline: (t, ctx) => ({type: "bold", value: t})}},
trackPositions: true,
});
// tokens[0].position → { start: {offset:0, line:1, column:1}, end: {offset:6, line:1, column:7} }
// tokens[1].position → { start: {offset:6, line:1, column:7}, end: {offset:21, line:1, column:22} }
坐标长什么样
SourcePosition
interface SourcePosition {
offset: number; // 0 起始,UTF-16 code unit 偏移
line: number; // 1 起始,行号
column: number; // 1 起始,列号
}
SourceSpan
interface SourceSpan {
start: SourcePosition;
end: SourcePosition;
}
每个 token 的 position 就是一个 SourceSpan——标记它在源码里从哪开始、到哪结束。
buildPositionTracker(text)
function buildPositionTracker(text: string): PositionTracker
位置追踪的核心引擎。它做的事情不复杂:
- 扫描一遍文本,记下每个换行符后面的偏移量(也就是每行第一个字符的位置),存成一张行偏移表
- 返回一个 PositionTracker,它有一个
resolve(offset)方法 - 你给
resolve一个偏移量,它在行偏移表里做一次二分查找,告诉你这个偏移量在第几行第几列
interface PositionTracker {
resolve(offset: number): SourcePosition;
}
重要: 建一次就够了,别对每个切片都重新建。下面讲子串解析时会用到这一点。
解析子字符串:baseOffset 和 tracker
实际场景中你经常需要只解析大文档的一部分——比如从 Markdown 里抠出一段 DSL 来解析。 这时坐标就麻烦了:解析器看到的是切片,但你希望报错坐标指向原始文档。
原始文档: "first line\nprefix $$bold(world)$$ suffix"
↑ offset 18
切片: "$$bold(world)$$"
↑ 切片内部 offset 0,但在原文里是 18
两种模式对比
模式 A:只传 baseOffset 模式 B:传 baseOffset + tracker(推荐)
┌──────────────────────┐ ┌──────────────────────┐
│ offset ✅ 正确偏移 │ │ offset ✅ 正确偏移 │
│ line ❌ 切片内的行号 │ │ line ✅ 原文行号 │
│ column ❌ 切片内的列号 │ │ column ✅ 原文列号 │
└──────────────────────┘ └──────────────────────┘
推荐做法
const fullText = "first line\nprefix $$bold(world)$$ suffix";
const tracker = buildPositionTracker(fullText); // 建一次!
const start = 18;
const slice = fullText.slice(start, 33);
const tokens = parseRichText(slice, {
handlers: {bold: {inline: (t, ctx) => ({type: "bold", value: t})}},
trackPositions: true,
baseOffset: start, // 告诉解析器切片的起点
tracker, // 用原文的行表来算 line/column
});
// tokens[0].position.start.offset → 18 (原文中的绝对位置)
// tokens[0].position.start.line → 2 (原文第 2 行)
// tokens[0].position.start.column → 8 (原文第 8 列)
这组例子已经按当前版本重测过,结果仍然是:
offset: 18line: 2column: 8
也就是说,baseOffset 只负责把切片坐标平移回原文;
tracker 决定 line / column 能不能也一起回指原文。
如果你少传一项,行为会立刻退化:
- 不传
trackPositions- 不会产出
position baseOffset和tracker都不会生效
- 不会产出
- 只传
trackPositions: true- 会有
position - 但坐标仍然以当前切片为本地原点
- 会有
- 传
trackPositions: true+baseOffsetoffset会平移回原文line/column仍然是切片内坐标
- 传
trackPositions: true+baseOffset+trackeroffset、line、column才会一起完整回指原文
直接看错位:不传 vs 传
真正容易让人记住的不是“推荐写法”,而是“错了会漂到哪里”。
同样还是这段原文:
first line
prefix $$bold(world)$$ suffix
我们只解析切片 "$$bold(world)$$",它在原文里从 offset 18 开始。
❌ 只传 baseOffset,不传 tracker
const tokens = parseRichText(slice, {
handlers: {bold: {inline: (t, ctx) => ({type: "bold", value: t})}},
trackPositions: true,
baseOffset: 18,
});
// tokens[0].position.start
// → { offset: 18, line: 1, column: 1 }
这里最坑的地方是:
offset看起来已经对了- 但
line/column仍然是切片内坐标 - 用户会误以为“已经回指原文了”,其实只回对了一半
✅ 传 baseOffset + tracker
const tracker = buildPositionTracker(fullText);
const tokens = parseRichText(slice, {
handlers: {bold: {inline: (t, ctx) => ({type: "bold", value: t})}},
trackPositions: true,
baseOffset: 18,
tracker,
});
// tokens[0].position.start
// → { offset: 18, line: 2, column: 8 }
这时三项才一起正确:
offset→ 原文绝对偏移line→ 原文第 2 行column→ 原文第 8 列
一句话记忆:
- 不传
tracker:位置会飘,尤其是line/column - 传了
tracker:报错和高亮才能真正对回原文
parseRichText vs parseStructural 的位置差异
两者的位置语义不同:parseRichText 反映规范化后的渲染范围,parseStructural 反映原始源码范围。
这意味着同一段源码,两个 API 给出的 position.end 不一样。以 $$info()*\nhello\n*end$$\nnext(27 字符)为例:
parseRichText 的 end
↓
$$info()*\nhello\n*end$$\n next
↑
parseStructural 的 end
| API | info 的 end.offset | 原因 |
|---|---|---|
| parseRichText | 23(吃掉尾部 \n) |
block 规范化时消耗了换行 |
| parseStructural | 22(停在 $$ 处) |
原始语法位置,不做规范化 |
Block 子节点也一样: parseRichText 会为前导换行做偏移调整,parseStructural 给你原始位置。
性能
parser 总基准、parseSlice 增量解析、深层嵌套 stress case 现已统一整理到性能。
这一页只保留“位置追踪本身贵不贵”这个问题。
关掉追踪就没有任何额外开销。打开之后,对大多数应用场景影响也很小。
| 状态 | 开销 |
|---|---|
trackPositions: false(默认) |
不分配行表、不产出 position 对象、几乎零开销 |
trackPositions: true |
行表扫一遍文本建好,之后每次查坐标走二分查找 |
追踪开销
这一节固定记录 1.1.6 基线,不再随之后的补丁版重测。
下面是 1.1.6 的实测基准(~200 KB 输入,204,840 bytes)。
测试环境:HiSilicon TaiShan-v110(鲲鹏 920)24 核 aarch64 / 32 GB / Node v24.14.0。
每项 20 次采样。
| API | 不开追踪 | 开追踪 | 额外开销 |
|---|---|---|---|
| parseRichText | ~22.45 ms | ~34.07 ms | ~51.8% |
| parseStructural | ~14.88 ms | ~18.49 ms | ~24.3% |
1.1.6 这组实测值说明:
parseRichText开追踪确实有明显成本,但仍处在编辑器可接受范围parseStructural的追踪开销更低,不过也不是“零成本”- 真正需要省预算时,优先盯
parseRichText + trackPositions
为什么 parseRichText 开追踪更贵?
因为它不只是“像 parseStructural 一样挂上坐标”,还要把坐标带进渲染语义里重新结算一遍。
parseStructural的追踪成本主要是两笔:- 扫一遍文本,建
PositionTracker - 给结构节点挂上原始源码位置
- 扫一遍文本,建
parseRichText除了这两笔,还要继续支付 render 层的额外成本:- 把 structural 节点的位置再映射到最终
TextToken.position - 相邻文本合并时同步维护 span
- block 规范化时修正前导 / 尾部换行带来的位置偏移
- 把 structural 节点的位置再映射到最终
最重要的一点是:parseRichText.position 和 parseStructural.position 不是同一种东西。
parseStructural.position表示原始源码范围parseRichText.position表示规范化后的渲染范围
所以 parseRichText 在开追踪时更贵,不是因为实现异常,而是因为它的 position contract 本来就更重。
增量解析实战:parseSlice 到底有多快
上面的基准说明了"追踪本身不贵",但真正能救命的是:配合 parseSlice(来自 yume-dsl-token-walker
),你可以只重新解析改动的那一小块,而不是整篇文档。
场景
这一节同样固定记录 1.1.6 基线,不再随之后的补丁版重测。
同一份 ~200 KB 文档(204,840 bytes),用户在文档中间改动一个 36 字符的 $$bold(...)$$ 标签。你需要拿到更新后的 token 树。
三种策略对比
策略 A:全量 parseRichText
┌──────────────────────────────────────────────┐
│ 把 200 KB 全部重新走一遍 parseRichText │
│ ≈ 19.45 ms │
└──────────────────────────────────────────────┘
策略 B:全量 parseStructural(每次重建)
┌──────────────────────────────────────────────┐
│ 和 A 速度接近,但产出结构树可供增量查找 │
│ ≈ 18.85 ms │
└──────────────────────────────────────────────┘
策略 C:parseStructural(缓存)+ parseSlice(增量) ← 推荐
┌──────────────────────────────────────────────┐
│ 首次:parseStructural 建树 ≈ 18.85 ms │
│ 之后每次编辑: │
│ 1. nodeAtOffset 定位改动节点 ≈ 0.457 ms │
│ 2. parseSlice 只解析那个节点 ≈ 0.008 ms │
│ │
│ 增量更新总耗时 ≈ 0.465 ms │
└──────────────────────────────────────────────┘
实测数据
| 步骤 | 耗时 | 说明 |
|---|---|---|
全量 parseRichText |
~19.45 ms | 200 KB 完整解析 |
全量 parseStructural + 追踪 |
~18.85 ms | 重建结构树和位置 |
nodeAtOffset 定位 |
~0.457 ms | 在旧结构树上定位命中节点 |
parseSlice 增量解析 |
~0.008 ms | 只解析 36 字符的小切片 |
buildPositionTracker 重建 |
~0.997 ms | 全文行偏移表扫描(仅换行变动时需要) |
| 增量总计(定位 + 切片) | ~0.465 ms | 只重解析命中的局部区域 |
为什么还有差距
parseRichText 虽然已经很快(200 KB ≈ 19.45 ms),但它仍要扫描整篇文档。 parseSlice 只切出改动节点覆盖的那一小段(这里是 36 个字符),在这个小切片上跑 parseRichText——其余 200 KB 完全不碰。
parseSlice 的耗时和切片大小成正比,和文档大小无关。一个 36 字符的节点,无论在 10 KB 还是 200 KB 的文档里,解析时间都在 ~0.008 ms 左右。对于单节点编辑场景,关键收益是把重解析工作限制在局部,而不是每次都重新扫描整篇文档。
代码:全量 vs 增量
import {createParser, createSimpleInlineHandlers, buildPositionTracker} from "yume-dsl-rich-text";
import {parseSlice, nodeAtOffset} from "yume-dsl-token-walker";
const parser = createParser({
handlers: createSimpleInlineHandlers(["bold", "italic", "color"]),
});
// ── 200 KB 文档 ──
let fullText = buildLargeDocument(); // ~200 KB 的 DSL 文本
// ═══════════════════════════════════════════
// 策略 A:全量——每次编辑都重新解析整篇
// ═══════════════════════════════════════════
const tokensA = parser.parse(fullText);
// ≈ 19.45 ms,每次编辑都要付这个代价
// ═══════════════════════════════════════════
// 策略 C:增量——首次建树,之后只解析改动区域
// ═══════════════════════════════════════════
// 第一步:建结构树(只做一次)
let tree = parser.structural(fullText, {trackPositions: true});
// ≈ 18.85 ms
// 第二步:建 tracker(只做一次)
let tracker = buildPositionTracker(fullText);
// ── 用户编辑了 offset 105407 附近的内容 ──
const editOffset = 105407;
fullText = applyEdit(fullText, editOffset, "旧词", "新词");
// 第三步:定位改动落在哪个节点(≈ 0.457 ms)
const hitNode = nodeAtOffset(tree, editOffset);
// 第四步:只解析那个节点(≈ 0.008 ms)
if (hitNode?.position) {
const freshTokens = parseSlice(fullText, hitNode.position, parser, tracker);
// freshTokens 的 position 指向原文的正确行列
}
关键要点
-
tracker 只建一次。
buildPositionTracker扫一遍全文建行偏移表(200 KB ≈ 1.00 ms)。如果文档换行结构没变(只改了行内内容),旧 tracker 仍然有效。如果插入/删除了换行,需要重建——但 1 ms 的重建成本也远低于全量解析。如果你不传 tracker,
parseSlice仍然能工作,但返回的 token 只能拿到正确的offset;line/column会退回切片本地坐标,不再直接对应原文。 -
结构树的价值在于增量定位。
parseStructural和parseRichText速度已经接近(200 KB:~18.85 ms vs ~19.45 ms),但 parseStructural 产出的结构树支持nodeAtOffset定位,这是增量解析的前提。全量解析时选哪个都行,增量场景必须用 parseStructural。如果你不给
parser.structural(..., {trackPositions: true})开位置追踪,结构树里就没有node.position,nodeAtOffset/parseSlice这条基于SourceSpan的增量路径也就没法成立。 -
parseSlice 的耗时和切片大小成正比,和文档大小无关。 36 字符的节点无论在 10 KB 还是 200 KB 的文档里,解析时间都一样。
-
位置坐标自动映射回原文。 传了 tracker 之后,parseSlice 返回的 token 的
position.line/position.column直接指向原始全文的行列,不需要手动换算。
什么时候该用、什么时候不该用
| 场景 | 建议 |
|---|---|
| 一次性解析(SSG、构建时) | 直接 parseRichText,没必要分两步 |
| 文档 < 200 KB | 直接 parseRichText,200 KB 也只要 ~19.45 ms |
| 超大文档 > 500 KB 或按键级实时编辑 | parseStructural + parseSlice,增量解析避免每次按键都全量扫描 |
| 批量 lint 几百个文件 | parseStructural + lintStructural,只需结构信息不必完整解析 |