zh CN 源码位置追踪 - chiba233/yumeDSL GitHub Wiki

源码位置追踪

Token 遍历 | 错误处理

你的 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

位置追踪的核心引擎。它做的事情不复杂:

  1. 扫描一遍文本,记下每个换行符后面的偏移量(也就是每行第一个字符的位置),存成一张行偏移表
  2. 返回一个 PositionTracker,它有一个 resolve(offset) 方法
  3. 你给 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: 18
  • line: 2
  • column: 8

也就是说,baseOffset 只负责把切片坐标平移回原文;
tracker 决定 line / column 能不能也一起回指原文。

如果你少传一项,行为会立刻退化:

  • 不传 trackPositions
    • 不会产出 position
    • baseOffsettracker 都不会生效
  • 只传 trackPositions: true
    • 会有 position
    • 但坐标仍然以当前切片为本地原点
  • trackPositions: true + baseOffset
    • offset 会平移回原文
    • line / column 仍然是切片内坐标
  • trackPositions: true + baseOffset + tracker
    • offsetlinecolumn 才会一起完整回指原文

直接看错位:不传 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 规范化时修正前导 / 尾部换行带来的位置偏移

最重要的一点是:parseRichText.positionparseStructural.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 指向原文的正确行列
}

关键要点

  1. tracker 只建一次。 buildPositionTracker 扫一遍全文建行偏移表(200 KB ≈ 1.00 ms)。如果文档换行结构没变(只改了行内内容),旧 tracker 仍然有效。如果插入/删除了换行,需要重建——但 1 ms 的重建成本也远低于全量解析。

    如果你不传 tracker,parseSlice 仍然能工作,但返回的 token 只能拿到正确的 offsetline / column 会退回切片本地坐标,不再直接对应原文。

  2. 结构树的价值在于增量定位。 parseStructuralparseRichText 速度已经接近(200 KB:~18.85 ms vs ~19.45 ms),但 parseStructural 产出的结构树支持 nodeAtOffset 定位,这是增量解析的前提。全量解析时选哪个都行,增量场景必须用 parseStructural。

    如果你不给 parser.structural(..., {trackPositions: true}) 开位置追踪,结构树里就没有 node.positionnodeAtOffset / parseSlice 这条基于 SourceSpan 的增量路径也就没法成立。

  3. parseSlice 的耗时和切片大小成正比,和文档大小无关。 36 字符的节点无论在 10 KB 还是 200 KB 的文档里,解析时间都一样。

  4. 位置坐标自动映射回原文。 传了 tracker 之后,parseSlice 返回的 token 的 position.line / position.column 直接指向原始全文的行列,不需要手动换算。

什么时候该用、什么时候不该用

场景 建议
一次性解析(SSG、构建时) 直接 parseRichText,没必要分两步
文档 < 200 KB 直接 parseRichText,200 KB 也只要 ~19.45 ms
超大文档 > 500 KB 或按键级实时编辑 parseStructural + parseSlice,增量解析避免每次按键都全量扫描
批量 lint 几百个文件 parseStructural + lintStructural,只需结构信息不必完整解析