zh CN 增量解析 - chiba233/yumeDSL GitHub Wiki

增量解析

性能 | API 参考 | 源码位置追踪

parseStructural(...) 已经很快,但在编辑器场景里,每敲一个键就跑一遍全量 structural 扫描——这开销你肯定不想要。这一页聊的就是 yume-dsl-rich-text 提供的增量 structural 缓存 API,帮你省掉那些没必要的重复计算。

createIncrementalSession(...) / parseIncremental(...) 是稳定的公开 API。 增量策略和启发式仍可能继续优化,但 session-first 合约、结果字段和全量回退语义属于稳定接入面。 生产环境依然建议固定版本,并保留一条你自己验证过的全量重建兜底路径。

增量 API 帮你维护两样东西:

  • tree:强制开启 trackPositions: trueStructuralNode[]
  • zones:由 buildZones(tree) 得到的 Zone[]

它跟 tree-sitter 那种节点级复用不是一回事。核心思路是"把脏区重新解析一遍,没碰到的 zone 直接复用"。

什么时候用

  • 文档比较大(几十 KB 往上),而且你在编辑器里需要按键级别反复跑 structural parse。
  • 你想维护一份稳定的 StructuralNode[] + Zone[] 缓存,拿来驱动高亮、outline、lint、增量管线之类的。
  • 你想把 structural 缓存和 yume-dsl-token-walkerparseSlice(...) 组合起来,实现"只重新解析被编辑区域"的 TextToken[] 生成。

如果你已经能定位到需要重解析的区域,而且只需要做 substring parse,那单用 parseSlice(...) 往往就够了,不用搬出整套增量机制。


快速开始

直接抄:

import {createIncrementalSession} from "yume-dsl-rich-text";

const handlers = {/* ... */};
const session = createIncrementalSession(initialSource, {handlers});

// 每次编辑
const result = session.applyEdit(
    {startOffset, oldEndOffset, newText},
    newSource,
);
render(result.doc.tree);

如果你要局部刷新,改用 applyEditWithDiff

const result = session.applyEditWithDiff(
    {startOffset, oldEndOffset, newText},
    newSource,
    undefined,
    {maxMilliseconds: 4},
);
refreshChangedPanels(result.diff);
render(result.doc.tree);

如果你手上没有 newSource,先拼一下:

const previous = session.getDocument();
const newSource =
    previous.source.slice(0, startOffset) +
    newText +
    previous.source.slice(oldEndOffset);

1.2.3 开始,low-level updater 不再公开导出,公共接入请用 session API。


核心概念

整套 API 只有两个角色:

parseIncremental(...) — 拍一张初始快照

吃进一整篇 source,跑一遍 structural parse,返回一个快照对象 doc(含 source / tree / zones)。

不负责后续编辑,只建立增量系统的初始状态。大多数情况下你不需要直接调它——createIncrementalSession 内部会帮你做这件事。只有你明确想手动持有初始快照对象时才需要。

createIncrementalSession(...) — 持续接收编辑的会话

返回一个基于闭包的状态容器,内部维护着一份 currentDoc(最新的 source / tree / zones),对外暴露四个方法:

方法 作用
getDocument() 读取当前快照(不推进状态)
applyEdit(edit, newSource, options?) 推进到下一版,自动选择增量或全量
applyEditWithDiff(edit, newSource, options?, diffOptions?) 同上,但额外返回一份结构化 diff(支持按次覆盖 diff 细化预算)
rebuild(newSource, options?) 放弃增量,直接全量重建

applyEditapplyEditWithDiff 都会真正推进 session 状态。后者不是"只看看 diff"的观察接口。如果你想试探 diff 而不影响真实状态,在副本 session 上操作。

什么时候用哪个

                       "我在接增量解析"
                             │
               ┌─────────────┼──────────────┐
               ▼             ▼              ▼
          首次打开文档?    正常编辑?       不信任缓存 /
                             │          配置大幅变更?
               │       ┌─────┴──────┐       │
               ▼       ▼            ▼       ▼
     createIncremental  需要知道     不需要   session
     Session(...)       哪里变了?    diff     .rebuild(...)
                             │       │
                             ▼       ▼
                      applyEdit   applyEdit
                      WithDiff      (...)
                        (...)
你想做的 用这个
首次加载,建立会话 createIncrementalSession(...)
每次编辑,推进缓存 session.applyEdit(...)
推进缓存 + 拿结构 diff session.applyEditWithDiff(...)
强制全量重建 session.rebuild(...)
读当前快照(不推进) session.getDocument()
手动持有初始快照(low-level) parseIncremental(...)

为什么 applyEdit 要同时传 editnewSource

  • edit 告诉 session 旧文档里哪一段被替换了——用于定位脏区、走增量。
  • newSource 告诉 session 替换后整篇文档长什么样——用于校验一致性、保证结果正确。

两者缺一不可:只有 newSource 就不知道哪里变了;只有 edit 就无法确认最终结果。


API 签名(1.4.x)

import {
    createIncrementalSession,
    parseIncremental,
} from "yume-dsl-rich-text";
parseIncremental(
    source
:
string,
    options ? : IncrementalParseOptions,
):
IncrementalDocument

createIncrementalSession(
    source
:
string,
    options ? : IncrementalParseOptions,
    sessionOptions ? : IncrementalSessionOptions,
):
{
    getDocument: () => IncrementalDocument;
    applyEdit: (
        edit: IncrementalEdit,
        newSource: string,
        options?: IncrementalParseOptions,
    ) => IncrementalSessionApplyResult;
    applyEditWithDiff: (
        edit: IncrementalEdit,
        newSource: string,
        options?: IncrementalParseOptions,
        diffOptions?: IncrementalDiffRefinementOptions,
    ) => IncrementalSessionApplyWithDiffResult;
    rebuild: (newSource: string, options?: IncrementalParseOptions) => IncrementalDocument;
}
API 推荐程度 干嘛的
createIncrementalSession(source, options?, sessionOptions?) 默认推荐 会话级安全入口(applyEdit 自动回退 + 自适应策略)
parseIncremental(source, options?) 可选(low-level) 手动构建初始快照

返回类型展开

interface IncrementalSessionApplyResult {
    doc: IncrementalDocument;
    mode: "incremental" | "full-fallback";
    fallbackReason?:
        | "INVALID_EDIT_RANGE"
        | "NEW_SOURCE_LENGTH_MISMATCH"
        | "EDIT_TEXT_MISMATCH"
        | "UNKNOWN"
        | "INTERNAL_FULL_REBUILD"
        | "FULL_ONLY_STRATEGY"
        | "AUTO_COOLDOWN"
        | "AUTO_LARGE_EDIT";
}

interface IncrementalSessionApplyWithDiffResult extends IncrementalSessionApplyResult {
    diff: TokenDiffResult;
}

interface TokenDiffResult {
    isNoop: boolean;
    patches: Array<{
        kind: "insert" | "remove" | "replace";
        oldRange: { start: number; end: number };
        newRange: { start: number; end: number };
    }>;
    unchangedRanges: Array<{
        oldRange: { start: number; end: number };
        newRange: { start: number; end: number };
    }>;
    ops: Array<
        | {
        kind: "splice";
        path: Array<{ field: "root" | "children" | "args"; index: number }>;
        field: "root" | "children" | "args";
        oldRange: { start: number; end: number };
        newRange: { start: number; end: number };
        oldNodes: StructuralNode[];
        newNodes: StructuralNode[];
    }
        | {
        kind: "set-text" | "set-escape" | "set-raw-content";
        path: Array<{ field: "root" | "children" | "args"; index: number }>;
        oldValue: string;
        newValue: string;
    }
        | {
        kind: "set-implicit-inline-shorthand";
        path: Array<{ field: "root" | "children" | "args"; index: number }>;
        oldValue?: boolean;
        newValue?: boolean;
    }
    >;
    dirtySpanOld: { startOffset: number; endOffset: number };
    dirtySpanNew: { startOffset: number; endOffset: number };
}

diff 各字段:

  • isNoop:当这次编辑没有产生结构 token 变化时为 truepatchesops 都为空)。
  • patches:顶层 token-index patch 列表(可多段)。
  • unchangedRanges:顶层稳定岛范围。
  • ops:路径感知的结构化操作(如 nested children / args splice、set-textset-raw-content)。
  • dirtySpanOld / dirtySpanNew:旧/新源码坐标系下的脏区间。

如果内部无法完成精确 diff 细化,applyEditWithDiff(...) 会保守退回成整树替换型 diff,不会中断 session 更新。

另外,对于已经走到 full-fallback 的超大文档,applyEditWithDiff(...) 可能会直接选择保守整树 diff,以避免病态深度细化开销。


策略与回退

策略选择

策略 行为
"auto"(默认) 大编辑直接全量;可能进入临时全量优先冷却窗口;非冷却期优先增量
"incremental-only" 优先走增量,仅在底层更新失败时回退全量
"full-only" 始终全量重建,fallbackReason 固定为 FULL_ONLY_STRATEGY

策略在 createIncrementalSession 的第三个参数 sessionOptions 中设置:

// 始终全量(调试 / 基线对比)
const session = createIncrementalSession(source, {handlers}, {
    strategy: "full-only",
});

// 始终优先增量,只在底层失败时回退
const session = createIncrementalSession(source, {handlers}, {
    strategy: "incremental-only",
});

sessionOptions 的所有字段都是可选的,未传的字段使用默认值。只想调整 auto 策略中的个别参数时,只传你要覆盖的那几个就行,其余保持默认:

// auto 策略(默认),但把大编辑阈值放宽到 40%
const session = createIncrementalSession(source, {handlers}, {
    maxEditRatioForIncremental: 0.4,
});

// auto 策略,缩短冷却窗口
const session = createIncrementalSession(source, {handlers}, {
    fullPreferenceCooldownEdits: 6,
});

// 同时调多个
const session = createIncrementalSession(source, {handlers}, {
    maxEditRatioForIncremental: 0.4,
    fullPreferenceCooldownEdits: 6,
    softZoneNodeCap: 128,
    diff: {
        refinementDepthCap: 64,
        maxComparedNodes: 20000,
        maxAnchorCandidates: 128,
        maxOps: 512,
        maxSubtreeNodes: 256,
        maxMilliseconds: 8,
    },
});

// 热路径可按次覆盖(不改 session 默认值)
const withDiff = session.applyEditWithDiff(
    edit,
    newSource,
    undefined,
    {maxMilliseconds: 4, maxOps: 256},
);

Auto 策略默认值

选项 默认值 作用
maxEditRatioForIncremental 0.2 max(替换长度, 插入长度) / 旧文档长度 超过阈值,直接全量
sampleWindowSize 24 自适应统计的滑动窗口大小
minSamplesForAdaptation 6 启动自适应判断所需最小样本数
maxFallbackRate 0.35 窗口内回退率超过阈值时,切入全量优先冷却
switchToFullMultiplier 1.1 若增量平均耗时 > 全量平均耗时 × 倍率,切入全量优先冷却
fullPreferenceCooldownEdits 12 冷却窗口编辑次数
softZoneNodeCap 64 纯 inline / 低 breaker 文档的软 zone 切分上限;越小 zone 越细,越大脏窗越宽
diff undefined session 级默认 diff 细化配置;不传时使用内部默认预算

diff 细化默认值(sessionOptions.diff / diffOptions

  • refinementDepthCap 控制 applyEditWithDiff(...) 继续做路径感知深层细化的最大深度。
  • 值越大,深树场景下保留的细粒度 ops 越多,但极端深度下开销也更高。
  • 值越小,会更早切到粗粒度 splice,通常更有利于最坏延迟。
  • 非法值会被归一化为默认值。
  • maxComparedNodes / maxAnchorCandidates / maxOps / maxSubtreeNodes / maxMilliseconds 共同构成默认 diff 硬预算:目标是“常态尽量细,超限立刻粗化”,而不是在任何输入上继续追求最小 patch。
  • 这些预算在内部由一个显式的 DiffBudgetState 累积和传播;预算超限属于预期内退化条件,不走 throw/catch 异常流,而是直接切到 coarse splice / conservative diff。
  • 当 session 这次编辑实际走的是增量路径时,applyEditWithDiff(...) 会先复用增量 dirty zone 的源区间,只在这段 root window 内做精细 diff;dirty window 外直接按稳定区处理,不再对整棵 root tree 做细化。
Diff 选项 默认值 作用
refinementDepthCap 64 applyEditWithDiff 的嵌套结构 diff 细化最大深度,超过后降级为较粗粒度 splice
maxComparedNodes 20000 单次 diff refinement 允许的节点比较总预算;超限后立刻退化到更粗粒度 diff
maxAnchorCandidates 128 单次 diff refinement 允许尝试的唯一锚点候选上限;超限后放弃锚点细化
maxOps 512 单次 diff refinement 允许产出的 structural diff op 上限;超限后回退到 root splice 级别
maxSubtreeNodes 256 仍允许做嵌套细化的子树规模上限;过大的子树直接 coarse splice
maxMilliseconds 8 单次 diff refinement 的软 wall-clock 预算;超时后退化到更粗粒度 diff

fallbackReason 对照

底层校验/一致性失败:

  • INVALID_EDIT_RANGE:offsets 对旧文本非法(负数、反向、越界)。
  • NEW_SOURCE_LENGTH_MISMATCHnewSource.length 和 edit delta 对不上。
  • EDIT_TEXT_MISMATCHedit.newTextnewSourcestartOffset 处的切片不一致。
  • UNKNOWN:非预期异常(多半是实现 bug 或外部异常)。

策略触发的回退:

  • AUTO_LARGE_EDIT:auto 策略觉得这次编辑改的太多了。
  • AUTO_COOLDOWN:auto 策略暂时进入全量偏好的冷却窗口。
  • FULL_ONLY_STRATEGY:你自己设了 strategy: "full-only"
  • INTERNAL_FULL_REBUILD:增量保护路径内部发现不对劲,主动升级成全量重建。

applyEdit 决策流程图

上半部分是 session 层的策略门控,下半部分是内部增量更新的实际工作流。

flowchart TD
    A["applyEdit(edit, newSource)"] --> B{strategy?}
    B -->|full - only| C["全量重建"]
    C --> C1["✗ full-fallback · FULL_ONLY_STRATEGY"]
    B -->|auto / incremental - only| D{"auto 且<br/>editRatio > 阈值?"}
    D -->|是| E["全量重建"]
    E --> E1["✗ full-fallback · AUTO_LARGE_EDIT"]
    D -->|否| F{"auto 且<br/>处于冷却窗口?"}
    F -->|是| G["全量重建"]
    G --> G1["✗ full-fallback · AUTO_COOLDOWN"]
    F -->|否| H["进入内部增量路径 ↓"]
    H --> V{"edit 范围<br/>校验通过?"}
    V -->|否| V1["✗ full-fallback · 校验错误码"]
    V -->|是| FP{"options 指纹<br/>兼容?"}
    FP -->|否| FP1["✗ full-fallback · INTERNAL_FULL_REBUILD"]
    FP -->|是| ZE{"zone 列表<br/>非空?"}
    ZE -->|否| ZE1["✗ full-fallback · INTERNAL_FULL_REBUILD"]
    ZE -->|是| ZL{"zone 数量<br/>> 1?"}
    ZL -->|否| ZL1["✗ full-fallback · INTERNAL_FULL_REBUILD<br/>(低 zone 守卫)"]
    ZL -->|是| TG{"尾部覆盖<br/>安全?"}
    TG -->|否| TG1["✗ full-fallback · INTERNAL_FULL_REBUILD"]
    TG -->|是| DZ["定位脏区 zone 范围"]
    DZ --> RP["重解析脏窗口<br/>+ 右边界稳定扩展"]
    RP --> BG{"扩展字节<br/>超预算?"}
    BG -->|是| BG1["✗ full-fallback · INTERNAL_FULL_REBUILD"]
    BG -->|否| RZ{"有右侧<br/>zone 待复用?"}
    RZ -->|否| AS
    RZ -->|是| SP{"右接缝<br/>seam probe<br/>通过?"}
    SP -->|否| SP1["✗ full-fallback · INTERNAL_FULL_REBUILD"]
    SP -->|是| AS["拼接:左侧原样 +<br/>重解析结果 +<br/>右侧惰性 delta 平移"]
    AS --> OK["✓ incremental"]
Loading

结果处理

session.applyEdit(...) 不抛异常——总是返回结构化结果对象。

const result = session.applyEdit(edit, newSource);
if (result.mode === "full-fallback") {
    console.log(result.fallbackReason);
}

多段编辑(IME / 格式化 / 协作合并)

增量接口设计上是一次调用对应一个 edit。如果编辑器一次提交了多段 patch,常见做法:

  1. 按编辑器的应用顺序逐个调用 session.applyEdit(...),每步传对应的 newSource。(推荐——出了问题定位容易)
  2. 在编辑器层把多段 patch 合并成一个大的 (startOffset, oldEndOffset, newText),然后只调一次。

如果你的 UI 会缓存 diff 结果用于展示,尽量避免"按帧合并后只保留最后一条"的覆盖策略。同一帧里如果连发多次编辑,后一次 isNoop: true 可能把前一次有意义的 diff 覆盖掉。

parseSlice(token-walker)配合

最典型的增量管线——"结构缓存负责定位,token-walker 负责局部 token 化":

  1. session.applyEdit(...) 维护最新的 doc.tree / doc.zones
  2. 用结构查询(比如 nodeAtOffset / enclosingNode)找到应该重算 tokens 的范围。
  3. parseSlice(fullText, node.position, dsl, tracker) 只把那个范围重新解析成 TextToken[]

能跑通的关键是:doc.treeposition 一定回指整篇 newSource(增量内部强制开了 trackPositions)。


性能

惰性 delta 平移(1.2.4+)

从 1.2.4 开始,右侧 zone 复用改成了惰性 delta 平移:每个 zone 只做 O(1) 的偏移量累积,节点位置要等到你真正去读 doc.treedoc.zones[i].nodes 时才物化。连续多次头部编辑的 delta 会自动叠加,不触发任何中间物化。

  • 头部编辑不再是性能黑洞。 以前每次编辑都要深拷贝整个右侧子树,现在只记一个数字。
  • 中部/尾部编辑和以前一样快——脏区本来就很小。
  • 唯一要"付账"的时刻是你实际遍历 doc.tree 做渲染/导出时。

Zone 切分与实测性能

增量解析的收益取决于 zone 数量——zone 越多,单次编辑需要重解析的窗口越小。

问题: 在 1.2.4 之前,buildZones(...) 只在 raw / block 节点处切分。纯 inline 文档只会产出 1 个 zone,等于跟全量一样。

解决方案: 1.2.4 的增量路径内部引入了 softZoneNodeCap(默认 64)——连续非 breaker 节点超过此数量时自动切分。切分点始终落在节点边界上,不影响 seam probe 不变量。公开 API buildZones(...) 不受影响。

const session = createIncrementalSession(source, {handlers}, {
    softZoneNodeCap: 128,  // 更大的 zone → 更少的签名比对开销,但脏窗更宽
});

低 zone 守卫

当上一快照的 zone 数量 ≤ 1 时,增量路径直接跳到全量重建——1 个 zone 没有任何左/右可复用结构,增量路径的全部开销都是浪费。触发时返回 INTERNAL_FULL_REBUILD

实测数据(1 MB 文档,鲲鹏 920 aarch64,Node v24.14.0)

场景 zone 数 增量 median 全量 加速比 GC 稳定性
纯 inline(softCap=64) 264 ~12 ms ~127 ms ~10× 50 次连续编辑无退化,median ~9 ms
稀疏 raw(每 50 行 1 个 breaker) 1571 ~12 ms ~130 ms ~10×
中等 raw(每 20 行 1 个 breaker) 3757 ~15 ms ~130 ms ~9×
密集 raw(每 10 行 1 个 breaker) 7015 ~19 ms ~130 ms ~7×
超密集 raw(每 3 行 1 个 breaker) 17871 ~38 ms ~136 ms ~3.5×

测试环境与性能页同机同口径。编辑位置固定在头部区域(最坏方向),单字符替换。

zone 不是越多越好——每个 zone 都有签名计算、assembly 拼接、delta shift 的固定常数开销。几百到几千 zone 是甜区,超过万级后 zone 开销反而占主导。

经验法则:只要文档长到全量重建开始让你感觉到延迟(比如 > 10 ms),zone 切分就能把增量拉到可用范围。

Mermaid:zone 切分决策

flowchart TD
    N["顶层节点序列"] --> IS{"节点是<br/>raw / block?"}
    IS -->|是| FH["flush 已累积软 zone"]
    FH --> HB["硬边界:独占 zone"]
    HB --> IS
    IS -->|否| ACC["累积到当前软 zone"]
    ACC --> CAP{"累积节点数<br/>≥ softZoneNodeCap?"}
    CAP -->|是| FS["flush 当前软 zone"]
    FS --> IS
    CAP -->|否| IS
Loading

实战建议

  • 文档很大且编辑频率高:用 session,让惰性平移帮你攒着,等到渲染帧再去读 doc.tree
  • 文档很大但只需要局部 token:组合 parseSlice(...) 收益最大。
  • 把 options 当作不可变配置:parseIncremental 会把 options 做快照存进 doc.parseOptions(handlers 里 plain object/array 字段会递归克隆)。
  • 保持 handlers 引用稳定:handler 函数引用不变 = fingerprint 不变 = 不会触发全量。

常见坑

  • oldEndOffset 是 exclusive 的,不是 inclusive。off-by-one 的 bug 出了挺难查的。
  • offsets 都是 JavaScript 字符串 offset(UTF-16 code units),不是字符数 / rune count。碰到 emoji 或 CJK 扩展字符时这个区别就出来了。
  • edit.newText 必须和 newSource 对得上。传了长度对但内容不对的进去,会直接被 EDIT_TEXT_MISMATCH 拦住。
  • raw/block 语法依赖真实换行:如果你在测试里写 "\\n"(反斜杠+n)而不是 "\n",解析行为会完全不一样。

parseIncremental(...) 契约(low-level)

常规编辑器接入请直接用 createIncrementalSession(...)

const doc = parseIncremental(source, options);

doc 的保证:

  • doc.source 和你传入的 source 完全一致。
  • doc.tree 来自 structural parse,内部强制开了 trackPositions: true
  • doc.zonesbuildZones(doc.tree) 的结果。
  • zone 用到的节点都带着源码位置(offset/line/column),坐标系基于 doc.source

options 的行为:

  • trackPositions 会被忽略/覆盖为 true
  • options 会被存成内部快照(handlers 里的 plain object/array 字段会做递归快照)。
  • 后续增量复用会比较内部的 options fingerprint(看的是 parser 的有效行为:syntax/forms/handler 函数身份)。
  • 每次传新的 options 对象,只要行为等价,就不会触发全量回退。

边界规则(内部 updater)

增量更新按 Zone[] 做边界稳定,不是随便选个窗口重算。理解下面这些规则能帮你弄清楚为什么某些情况下它会回退全量。

  1. 初始脏区(zone 级):
    • findDirtyRange 用单遍线性扫描同时完成 overlap 检测和插入点定位(1.2.5 起合并为一次遍历)。
    • 如果 edit 和已有 zone 有交集:初始脏区范围是"相交区间 + 左边 1 个 zone + 右边 1 个 zone"。多扩一个 zone 是为了确保编辑边界处的上下文不丢失。
    • 如果 edit 是纯插入且没有和任何 zone 相交:初始范围取插入点两侧的相邻 zone。
  2. 右边界稳定扩展:
    • 重解析当前脏区之后,如果重解析末端 offset 和当前脏区右端 offset 不一致,说明结构还没稳定,继续向右扩 1 个 zone。一直扩到右边界稳定或到了文档末尾。
  3. 右接缝 seam probe 复用校验:
    • 右侧 zone 要复用,必须先过 seam probe:在拼接边界重新解析一小段,比较产出的 zone 签名和旧的右侧 zone 签名。
    • 签名用有界采样哈希(头尾各 32 字符),O(1) 完成单节点比对。
    • probe 没过就直接回退全量——宁可慢一次也不要拼出错误的树。
  4. 扩展预算保护:
    • 右扩过程中有累计重解析字节预算(2 × newSource.length)。超过阈值就自动回退全量,防止极端情况下反复扩窗把性能打穿。
  5. 右侧惰性 delta 平移(1.2.4+):
    • 通过 seam probe 的右侧 zone 只记 pendingDelta,节点位置到消费时才物化。连续编辑的 delta 自动叠加。
  6. 快照异常回退:
    • 输入快照本身有问题(zones 为空、尾部 coverage 异常),直接回退全量重建。
  7. 全量回退路径的常数优化(1.2.5+):
    • cloneParseOptions 延后到增量路径确认后才执行。
    • 增量路径已构建的 positionTracker 在 fallback 全量重建时直接复用。

版本语义说明

增量 API 的版本差异、升级影响,以及像 1.4.1 这类 EOF 未闭合 inline 恢复语义变化,现在统一收在:


类型速查

IncrementalEdit

export interface IncrementalEdit {
    startOffset: number;
    oldEndOffset: number; // exclusive,基于旧文本 offsets
    newText: string;
}

startOffset / oldEndOffset 基于旧文本(session.getDocument().source)。newText 必须与 newSource.slice(startOffset, startOffset + newText.length) 一致。

IncrementalDocument

export interface IncrementalDocument {
    source: string;
    zones: readonly Zone[];
    tree: readonly StructuralNode[];
    parseOptions?: IncrementalParseOptions;
}

IncrementalParseOptions

export type IncrementalParseOptions = Omit<
    StructuralParseOptions,
    "trackPositions" | "baseOffset" | "tracker"
>;

它和 StructuralParseOptions 几乎一样,只是去掉了增量内部自己管理的三个字段。实际可用的字段:

字段 来源 说明
handlers ParserBaseOptions 标签处理器映射(保持引用稳定可提升增量命中率)
allowForms ParserBaseOptions 允许的标签形式(inline / raw / block
implicitInlineShorthand ParserBaseOptions 隐式 inline 简写开关
depthLimit ParserBaseOptions 最大嵌套深度(默认 50)
syntax ParserBaseOptions 自定义 DSL 语法标记
tagName ParserBaseOptions 自定义标签名字符规则
trackPositions 被 Omit 增量路径内部强制开启,不可覆盖
baseOffset 被 Omit 增量切片更新的内部参数
tracker 被 Omit 增量切片更新的内部参数

也就是说,如果你已经会用 parseStructural 的 options,那 createIncrementalSession 的 options 参数直接把同一个对象传进去就行——被 Omit 的字段传了也会被忽略(TypeScript 类型层面会报错,但语义上不影响)。

IncrementalSessionOptions

export interface IncrementalSessionOptions {
    strategy?: "auto" | "incremental-only" | "full-only";
    sampleWindowSize?: number;
    minSamplesForAdaptation?: number;
    maxFallbackRate?: number;
    switchToFullMultiplier?: number;
    fullPreferenceCooldownEdits?: number;
    maxEditRatioForIncremental?: number;
    softZoneNodeCap?: number;
    diff?: IncrementalDiffRefinementOptions;
}

export interface IncrementalDiffRefinementOptions {
    refinementDepthCap?: number;
    maxComparedNodes?: number;
    maxAnchorCandidates?: number;
    maxOps?: number;
    maxSubtreeNodes?: number;
    maxMilliseconds?: number;
}

softZoneNodeCap 控制增量内部 zone 构建的软切分上限,默认 64。最小有效值 2(内部 clamp)。

类型派生

增量类型的公开导出面刻意保持小而稳。diff 的细粒度片段类型故意不从 root 单独导出——稳定主契约是 TokenDiffResult ,需要更细类型时从字段派生:

type DiffPatch = TokenDiffResult["patches"][number];
type DiffRange = TokenDiffResult["unchangedRanges"][number];
type DiffOp = TokenDiffResult["ops"][number];

导出

import {
    createIncrementalSession,
    parseIncremental,
} from "yume-dsl-rich-text";

import type {
    IncrementalDiffRefinementOptions,
    IncrementalDocument,
    IncrementalEdit,
    IncrementalParseOptions,
    IncrementalSessionApplyResult,
    IncrementalSessionApplyWithDiffResult,
    IncrementalSessionOptions,
    TokenDiffResult,
} from "yume-dsl-rich-text";
⚠️ **GitHub.com Fallback** ⚠️