tp/docs/REPOSITORY-AUDIT.md
Kinneyzhang 972b6d4e4c Complete text-property facade and managed lifecycle
Add canonical query semantics, managed metadata and transactions, overlay-aware lookup, reproducible benchmarks, and synchronized API documentation.
2026-07-28 22:42:55 +08:00

69 KiB
Raw Blame History

tp 仓库系统审计与“文本属性操作替代层”能力评估

审计日期2026-07-28 审计快照:a65d799tp 0.3.0 结论置信度:高 范围:公开 API 语义、核心实现、层栈与响应式设计、Emacs 原生语义覆盖、 测试与 CI、性能风险、文档一致性以及后续扩展路线。

实施状态2026-07-28阶段 0 语义已冻结在 API-SEMANTICS.md;阶段 1 的 TP-A01A06、TP-A10 与 TP-A11 已按本文路线实现回归测试和修复;阶段 2 canonical façade 已完成为 内部记录/数据流模型,公开返回保持兼容;阶段 3 text-only 原生语义 façade 已完成;阶段 4 managed lifecycle 已完成;阶段 5 overlay-aware 查询已完成, overlay lifecycle 明确排除。下文的问题描述保留为审计快照证据,不代表 修复后工作树的现状。

当前验收结论:按路线编号共有阶段 056 个阶段;若只统计开发阶段则为 15 共 5 个现已全部完成。tp 已能作为日常文本属性操作的统一高层 façade并对 direct/effective/source、显式 nil、change/predicate、 mutation policy、managed lifecycle 和 overlay-aware lookup 给出公开契约。 “替代”不表示重写 Emacs 引擎overlay lifecycle、yank/stickiness/undo 等底层机制继续显式委托原生 API。

0. 结论先行

0.1 直接回答

如果“替代所有文本属性操作”是指:

  1. 用更统一、易记的接口完成日常的属性设置、查询、删除、批量匹配;
  2. 同时提供原生 API 没有的命名层、层栈、响应式属性、调色板等高层能力;

那么 tp 已经基本具备成为首选高层工具箱的条件

如果“替代”是指:

  1. tp 的每个 API 在字符串与缓冲区上都具有稳定、可预测、统一的语义;
  2. 可以覆盖或等价映射 GNU Emacs 的全部文本属性读取、写入、边界搜索、 插入继承、分类默认值、撤销、复制/yank、特殊属性行为
  3. 进一步覆盖 get-char-property 所代表的文本属性与 overlay 联合视图;
  4. 现有用户可以不再理解 Emacs 原生语义而只依赖 tp

那么答案是 目前不能

当前最准确的定位应当是:

tp 是建立在 Emacs 原生文本属性之上的高层操作工具箱,并额外提供一套 受管理的命名层、层栈与响应式渲染模型。

不宜在当前版本中把它描述为:

Emacs 全部文本属性/字符属性操作的语义等价替代品。

0.2 为什么还不能称为“完整替代”

主要原因不是函数数量不足,而是以下四个语义问题:

  1. 公共 API 契约尚未完全收敛。 同一函数在字符串和缓冲区上可能采用不同 的修改方式、坐标、返回类型与搜索结果结构。
  2. “属性不存在”和“属性存在但值为 nil”没有被全程区分。 这会直接破坏 精确查询、搜索、覆盖和删除语义。
  3. 原生文本属性模型与 tp 的受管理层模型有两个不同的所有权系统。 tp-set 展开命名层后通常不保留身份,而 tp-push-layer/tp-put-layer 会写入 tp-name/tp-layers;二者不能互换。
  4. Emacs 的完整语义远大于“给区间写 plist”。 分类默认值、属性别名、 stickiness、插入继承、撤销、yank 过滤、special properties、overlay-aware 查询等仍没有成为 tp 的明确 API 契约。

此外,本次审计确认了数个当前测试未覆盖的实现问题,其中至少两个会造成用户 可见的属性错误,见第 6 节

0.3 三个替代层级

建议把“替代”拆成三个可验证层级,而不是使用一个无法验收的口号:

层级 定义 当前状态
A. 日常操作替代 常见设置、读取、删除、匹配、遍历可优先使用 tp 较强,接近可用
B. 原生文本属性 API 语义替代 对原生读取、写入、搜索、复制、nil 值、坐标和返回值有明确等价契约 text-only 已达到,公开返回仍兼容历史
C. 完整字符属性生态替代 明确覆盖 category/default/alias、stickiness、特殊属性、yank、undo、overlay-aware 查询等 查询边界已达到overlay lifecycle 明确排除

合理的近期目标是先完整达到 BC 应当被设计成显式兼容层,而不是重新实现 Emacs 的底层属性引擎。


1. 审计方法与证据边界

本报告使用了四类证据:

  1. 源码审计:逐模块检查公开入口、解析器、属性合并、层栈编解码、响应式 注册与刷新路径。
  2. 文档交叉检查:比较 README、函数 docstring、架构文档和 CHANGELOG。
  3. 动态验证运行完整编译、ERT、doctest、随机顺序测试并对关键语义 编写最小批处理复现。
  4. 官方语义基线:以 GNU Emacs Lisp Reference Manual 的 Text PropertiesExamining PropertiesChanging PropertiesProperty SearchSticky PropertiesSpecial Properties 为比较基线。

报告使用以下证据标记:

标记 含义
已确认 有源码路径和动态复现,或有明确测试证明
高置信推断 源码控制流明确,但尚未加入永久回归测试
需阈值 已有 benchmark 基线,但尚未固化为发布阈值

本报告没有把“测试全绿”等同于“没有问题”。现有测试主要证明已经写入测试的 行为;本次发现的问题恰好说明,缺少跨 API 等价性和状态空间测试时,固定回归 测试无法证明语义完备性。


2. 仓库现状

2.1 模块结构

当前代码采用线性模块边界:

tp-core
  → tp-reactive
  → tp-layer
  → tp-ops
  → tp-search
  → tp-render
  → tp-stack
  → tp-palette
  → tp-builtins
  → tp.el

职责总体清晰:

模块 当前职责 审计评价
tp-core.el 区间、plist/face 合并、底层工具 边界清晰,但部分“便利查询”会丢失位置信息
tp-reactive.el 依赖、watcher、批量队列、缓冲区注册表 机制完整,但生命周期依赖所有写入都经过登记入口
tp-layer.el 层定义、解析、展开、栈存储编码 是语义复杂度最高的所有者,直接应用与受管理应用混在同一解析体系
tp-ops.el 核心增删改查、tp-text 重载较多;字符串/缓冲区契约未完全统一
tp-search.el 模式、搜索、导航、替换 功能丰富,但返回模型与 nil 查询语义分裂
tp-render.el 响应式重渲染、最小文本更新 已有差异更新意识,但层重新定义的 old/new 所有权不足
tp-stack.el 命名层栈操作 特性强;本质上是独立于原生 plist 的受管理状态机
tp-query.el 原生文本 lookup/change 封装、修改策略 text-only Stage 3 与 overlay-aware Stage 5 查询已完成
tp-palette.el 主题色数据 独立性好;主题变化后的已应用结果没有完整刷新协议
tp-builtins.el 内置层和显示辅助 适合作为上层可选能力,不应决定核心属性语义

模块化本身是成功的。当前最大的架构问题已经不再是“文件太大”,而是:

原生属性操作、层定义展开、受管理层栈、响应式重渲染在 API 层共享了过多 隐式规则,却没有共享一个完整的语义规范。

继续拆文件不会自动解决这个问题;首先应收敛状态所有权和公开契约。

2.2 当前质量基线

项目 当前结果
包版本 0.3.0
Emacs 基线 28.1+
CI 矩阵 28.1 / 29.4 / 30.1
ERT 636 个,全部通过
doctest 92 个断言,全部通过
随机顺序 ERT 636 个,全部通过;本次种子 747555
字节编译 warning-as-error 下通过

这说明仓库已经有良好的维护基础,尤其是模块加载、固定回归、文档示例和多版本 兼容性方面。问题主要集中在未被现有用例枚举到的语义组合,不是基础工程 完全缺失。


3. 设计上做得好的部分

3.1 set / reset / add 给出了有价值的高层词汇

README 把三者区分为:

  • tp-set:只覆盖指定键,保留其他键;
  • tp-reset:完整替换区间属性;
  • tp-add:对 face 和嵌套 plist 做合并。

这个词汇比直接记忆多个原生函数更容易使用,也确实表达了三个常见意图。 尤其 tp-add 对 face 合并的封装,是原生 API 之上的真实增值,不是简单改名。

3.2 字符串和缓冲区共用同一组概念

核心 API 能处理字符串、当前缓冲区和显式缓冲区,模式/正则/搜索/层栈也尽量 保持两类对象都可用。这是合理的产品方向。问题不是“应否统一”,而是当前统一 只发生在函数名层面,坐标、修改性和返回值尚未完全统一。

3.3 层栈解决了原生文本属性缺少来源身份的问题

原生文本属性只保存当前每个字符的 plist不记录“这个 face 是哪个业务层写入 的”。tp 用 tp-nametp-layers 保存来源与顺序,使以下操作成为可能:

  • 按名称删除某一层,而不是猜测具体属性值;
  • 上移、下移、旋转、隐藏、显示;
  • 保留下层并只渲染当前顶层;
  • 更新被覆盖或隐藏的响应式层;
  • 将多个业务状态组合在一个文本范围上。

这是仓库最有差异化的能力,应继续作为核心卖点。

3.4 响应式系统已经考虑了增量更新和生命周期

当前实现有:

  • 变量 watcher
  • 依赖注册;
  • 层到缓冲区的索引;
  • buffer-local 值;
  • 批量更新;
  • 已隐藏/埋藏层的写穿;
  • tp-text 的最小编辑更新;
  • 匿名层回收;
  • 对“插入已带属性字符串绕过登记”的已知缺口给出手工跟踪入口。

这不是一个简单 demo。不过这也意味着它已经是一套小型渲染系统需要以 状态机和生命周期的标准来测试,而不能只按几个属性函数来测试。

3.5 CI 和文档覆盖明显高于一般小型 Elisp 库

多版本编译、warning-as-error、完整 ERT、随机顺序和 doctest 都是有效资产。 docs/ARCHITECTURE.md 也对模块职责和反向钩子边界做了明确说明。后续不应推翻 这些基础,而应补充“语义规范”和“等价性测试”两块缺失拼图。


4. “完整替代”所需的 Emacs 语义基线

4.1 原生模型不是稳定的 interval 对象模型

Emacs 的基本模型是:

  • 字符串或缓冲区中的每个字符携带一个属性 plist
  • [START, END) 是操作范围;
  • property change functions 从值变化推导边界;
  • interval 是实现和检查视图,不是用户持有的稳定业务实体。

GNU 手册专门说明了 Why Text Properties are not Intervals

这意味着 tp 可以提供 tp-intervals 作为便利视图,但不应把 interval 的分割 方式当成稳定身份,也不应让公开返回值依赖内部 interval 碎片数量。

当前多个层栈修改函数返回“改写的 property run 数量”。这个数会随无关属性 边界变化而变化,因此更像调试指标,不是稳定的业务结果。

4.2 读取不是简单的 plist-get

原生读取可能涉及:

  1. 字符上的直接文本属性;
  2. category 符号 plist 提供的默认值;
  3. default-text-properties
  4. char-property-alias-alist
  5. get-char-property 而言,还包括 overlay 与优先级。

因此以下三个问题必须明确区分:

  • 该字符是否直接携带属性键;
  • Emacs 解析后该字符的有效属性值是什么;
  • 这个值来自文本、category/default/alias还是 overlay。

当前 tp 的特定属性读取会调用 get-text-property,因此会间接继承 category/default/alias 的有效值解析;而“全部属性”和 presence 查询使用 text-properties-at,只看到直接 plist。问题不是底层能力完全缺失而是公开 API 没有明确命名 direct/effective/source 三种视图,也没有相应兼容性测试。

4.3 nil 是合法值,不等于缺失

在 plist 模型中:

(property nil)

与完全没有 property 键不是同一状态。前者可以显式遮蔽较低优先级来源。

因此任何完整替代 API 都必须为下列查询提供不同结果:

  1. 缺少键;
  2. 存在键且值为 nil
  3. 存在键且值为非 nil
  4. 调用者没有传 VALUE表示匹配任意值。

当前 tp 在若干路径中使用 plist-get 的真值或参数 nil 同时表达多个状态, 这是最需要优先消除的语义根因之一。

4.4 写属性会参与修改、撤销和 hook

GNU Emacs 中,缓冲区文本属性变化通常会:

  • 设置 buffer modified
  • 进入 undo
  • 触发相关 modification hooks
  • read-onlyinhibit-read-only 影响。

with-silent-modifications 可以用于不应污染 modified/undo 的纯属性更新,但它 有明确边界,不能粗暴包住真实文本修改。

tp 当前一些路径尊重原生行为,另一些路径通过 tp-with-current-buffer 无条件绑定 inhibit-read-only。如果目标是原生语义替代,这种“默认强制写入”必须成为 显式策略,而不是辅助宏的隐藏副作用。

4.5 属性会影响未来编辑和交互

front-stickyrear-nonstickytext-property-default-nonsticky 决定新文本 怎样继承属性;insert-and-inherit 与普通 insert 又不同。

此外,以下属性不是被动数据:

  • read-only
  • modification-hooksinsert-in-front-hooksinsert-behind-hooks
  • invisibledisplaycomposition
  • keymaplocal-maphelp-echo
  • field
  • cursor-intangiblecursor-sensor-functions
  • yank-handler

tp 可以通过通用 setter 写入这些属性,实际行为仍由 Emacs 执行;但“可以写入 一个键”不等于“tp 已经完整建模它的读取、继承、复制、冲突和生命周期语义”。

4.6 overlay 不是文本属性,但字符属性查询会看到它

overlay

  • 不随复制文本一起复制;
  • 属性变化不进入 buffer modified/undo
  • 有独立的优先级、边界和 hook
  • 会被 get-char-property、字段和显示相关语义看到。

因此建议把目标写清楚:

  • “完整文本属性 façade”可以不提供 overlay 创建/移动 API
  • 但如果承诺替代 get-char-property 或“所有字符属性操作”,就必须提供 overlay-aware 查询模式和明确的来源信息。

5. 当前能力矩阵

状态说明:

  • 较完整:当前 API 足以承担主要工作,契约也基本清楚;
  • 部分:有入口,但存在语义缺口或对象间不一致;
  • 间接:可以写入原生键,行为由 Emacs 执行tp 没有独立建模;
  • 缺失:没有与目标相匹配的公开能力;
  • 超出当前边界:不一定应该实现,但必须在定位中排除。
语义面 当前能力 状态 主要缺口
任意属性键写入 tp-set/reset/add 较完整 重载、返回值、read-only 策略仍不统一
单键增改 tp-set 较完整 名字与原生 set-text-properties 易混淆
全 plist 替换 tp-reset 较完整 tp-text 的属性保留说明需更精确
face/嵌套 plist 合并 tp-add 较完整 自定义合并规则需正式规范化
单键/多键删除 tp-removetp-clear 部分 字符串子属性删除可留下“存在但为 nil”的键
位置读取 tp-attp-membertp-lookup 已达到 Stage 3/5 overlay lifecycle 不属于 lookup
区间读取 tp-gettp-intervals 部分 tp-get 丢弃 nil 值;坐标模式不统一
属性汇总 tp-plist 部分 last-value-wins丢失属性对应的区间信息
属性边界 tp-intervals、内部 map、tp-property-change 已达到 text-only Stage 3 public interval relative 默认作为兼容例外保留
搜索相等值 tp-forward/backward/searchtp-property-any/not-all 已达到 text-only Stage 3 字符串/缓冲区 public 返回仍保持历史兼容差异
predicate 搜索 tp-forward/backward/search 已达到 text-only Stage 3 public 返回仍兼容历史
模式/正则批量写入 tp-match-*tp-regexp-* 较完整 默认绕过 read-only 的策略需显式化
字符串复制式操作 整串 tp-set/reset/add 较完整 与字符串区间原地修改形成两套模型
字符串长度变化替换 tp-text 可返回新字符串 部分 初次多 interval 属性合并存在错误
category 默认值 tp-lookup :mode :text-source/:text-effective 已达到 text-only Stage 3 overlay lifecycle 不参与
default-text-properties tp-lookup :mode :text-source/:text-effective 已达到 text-only Stage 3 overlay lifecycle 不参与
property alias tp-lookup :mode :text-source/:text-effective 已达到 text-only Stage 3 overlay lifecycle 不参与
text-only / char-property 查询 tp-lookup text/char modes 已达到 Stage 3/5 overlay 创建/移动/删除/priority 管理排除
stickiness/插入继承 直接委托 Emacs 已文档化并测试 无 tp wrapper
buffer modified/undo tp-with-mutation-policy + Emacs 副作用 三种组合已文档化并测试 三种组合之外不承诺统一策略
silent property update tp-with-mutation-policy 已达到 Stage 3 :silent + :respect 明确拒绝
kill/yank 属性处理 直接委托 Emacs 已文档化并测试 无 tp wrapper
特殊显示/交互属性 可通用写入,行为委托 Emacs 已文档化 无 source/priority wrapper
narrowing 依赖原生缓冲区 已文档化并测试 -
indirect buffer 依赖原生共享文本 已文档化并测试 -
命名层/层栈 define-tp、stack API、managed lifecycle API Stage 4 已达到 compositional layer 仍是可选扩展
响应式重渲染 watcher + registry + render + theme generation diagnostics Stage 4 已达到 benchmark 基线已记录,非发布阈值
overlay 创建/移动/删除 明确排除 使用 Emacs 原生 overlay lifecycle

5.1 能否设置所有原生属性

多数情况下可以通过通用 plist 写入任意属性键,因此 tp-set 的表达力不受一个 固定 allowlist 限制。

tp--builtin-text-properties 被用于禁止层名与 原生属性名冲突。这个手工列表:

  • 不可能天然随 Emacs 新版本保持完整;
  • 已缺少如 cursor-sensor-functions 等官方属性;
  • 同时包含 evaporate 这类 overlay-only 概念。

所以它不应被视为原生属性语义的权威目录。更稳妥的做法是给 tp 层使用独立命名 空间,或只禁止 tp 自己的保留元数据键;不要试图手工穷举 Emacs 属性名。


6. 已确认的问题与风险排序

6.1 汇总

ID 优先级 问题 证据 影响
TP-A01 P0 初次 tp-text 应用会把位置 0 的内嵌属性扩散到后续 interval 动态复现 + 源码 输出属性错误
TP-A02 P0/P1 静态层重定义不刷新;已触发刷新时旧键仍可残留 两组动态复现 + 源码 已显示内容与定义不一致
TP-A03 P1 显式 nil 无法覆盖 tp-text 内嵌属性 动态复现 + 源码 nil 语义错误
TP-A04 P1 搜索 API 把省略 VALUE 与 VALUE=nil 混为一体,且字符串/缓冲区结果不一致 动态复现 + 源码 无法精确搜索 nil
TP-A05 P1 字符串子属性删除可留下 (PROPERTY nil),缓冲区路径则移除键 动态复现 + 源码 同一 API 两种状态
TP-A06 P1 NOERROR 捕获所有内部错误 动态复现 + 源码 真实 bug 被静默吞掉
TP-A07 P1 直接层应用与层栈应用的身份语义分裂 源码 + 文档/测试 用户难以预测后续层操作
TP-A08 P1 修改性、坐标和返回值在对象/函数族之间不一致 源码 + 文档 很难成为稳定 façade
TP-A09 P1 多处文档与实际实现矛盾 源码/docstring 用户按文档编程会出错
TP-A10 P1 隐藏层存在时,外部直接属性编辑会被后续栈操作静默丢弃 动态复现 + 源码明确说明 用户属性数据丢失
TP-A11 P1/P2 transform/compute 等业务计算失败后 fallback/跳过observer 错误又无结构化报告 源码 产生貌似有效但错误或过期的输出
TP-A12 P2 响应式注册依赖入口完整性,插入带属性字符串需手工登记 源码明确承认 更新可能延迟或漏掉
TP-A13 P2 缺少全矩阵原生等价性和 CI 性能阈值 测试审计 无法证明“完整替代”与性能 SLA

6.2 TP-A01初次 tp-text 多 interval 属性扩散

状态:已确认。

tp--merge-string-props-into-plist 明确只读取字符串 位置 0 的属性。初次 tp-text 处理路径随后把这一结果作为整个替换区域的基础 属性;虽然部分路径又按 interval 应用属性,但位置 0 的值已经被提前合入,造成 后续 interval 污染。

最小语义场景:

FINAL-TEXT:
  [0,2) face=bold
  [2,4) face=italic

本次批处理复现中:

  • 字符串初次应用的后半段同时得到 bolditalic
  • 缓冲区初次应用把 bold 扩散到整个替换范围。

而后续响应式更新已有按 interval 保留属性的专门路径 (tp-render.el)。这说明当前同一功能的“初次应用” 和“更新应用”使用了两套不完全一致的合并引擎。

现有 tp-render-tests.el 覆盖了后续响应式更新, 没有覆盖初次应用的这个状态。

根因:

不是简单漏了一个条件,而是“字符串内嵌属性的所有者”在两个阶段不一致:

  • 一条路径把位置 0 属性提升为全区域属性;
  • 另一条路径把内嵌属性视为 per-interval 数据。

修复方向:

只保留一个 per-interval 合并入口。禁止任何初次应用路径把 position 0 的属性 当成整串代表值。修复前先加入字符串与缓冲区两组红色回归测试。

6.3 TP-A02层重定义存在两条独立的刷新缺陷

状态:两条均已确认。

A. 静态层重定义不触发已应用区域刷新

静态 simple 层的定义路径只更新 tp-layer-alist,没有调用 tp--layer-refreshtp-layer.el)。

复现流程:

(tp-layer-reset)
(define-tp audit-static () '(face bold help-echo "old"))

;; 在临时缓冲区插入 "abc" 并 push audit-static 后:
(define-tp audit-static () '(face italic help-echo "new"))

实际:

face=bold
help-echo="old"
stack=((audit-static face bold help-echo "old"))

预期:

face=italic
help-echo="new"
stack=((audit-static face italic help-echo "new"))

因此 face 也完全不刷新,这与“旧键残留”不是同一个问题。

B. 已触发 refresh 时,只更新新键,不删除旧键

响应式层重定义会触发 refreshtp--merge-props-into-stack-entry 只复制旧 entry 后写入新 plist 出现的键; tp--update-layer-regions 也只遍历新 props 做 put-text-property

复现把响应式层从:

(face (:foreground $audit-fg) help-echo "old")

重定义为:

(face (:background $audit-bg))

实际:

face=(:background "blue")
help-echo="old"
stack=((audit-reactive face (:background "blue") help-echo "old"))

预期 help-echo 被删除。

共同根因:

层定义更新没有统一的生命周期协议:

  • 静态定义更新没有进入刷新路径;
  • 刷新路径又只持有“新层定义”,没有完整替换“旧层 entry”。

修复方向:

所有可刷新 managed layer 都应经过同一 old/new entry 替换路径,再从完整 stack 重建可见 plist。对于直接应用但不保留 tp-name 的层,应明确它是一次性模板 展开,不承诺随定义更新。

6.4 TP-A03显式 nil 不能覆盖内嵌属性

状态:已确认。

tp--merge-embedded-props 使用:

(let ((existing (plist-get result key)))
  (if existing ... embedded-value))

当调用者明确传入 (custom nil) 时,plist-get 返回 nil代码把它误判为 “调用者没有这个键”,最终让内嵌值覆盖显式 nil。

本次复现:

内嵌字符串custom=embedded
调用属性custom=nil
期望custom 键存在,值为 nil
实际custom=embedded

修复方向:

凡是判断“键是否由调用者提供”必须使用 plist-memberplist-get 只用于取值。 随后应全库搜索相同模式,而不是只修这一处。

6.5 TP-A04VALUE=nil 无法表达精确搜索

状态:已确认。

tp-search 的条件是:

(or (null value)
    (equal prop-val value))

因此 nil 同时表示:

  • 调用者省略 VALUE匹配“属性存在且任意值”
  • 调用者明确传入 nil匹配“属性存在且值为 nil”。

两者无法区分。

更严重的是 tp-forward

  • 字符串路径委托给 tp-searchnil 是 wildcard
  • 缓冲区路径把 nil 传给 text-property-search-forward 并固定 predicate 为 equal,语义不同;
  • 字符串返回前 N 个 (START END VALUE) 列表;
  • 缓冲区返回第 N 次搜索的 prop-match

本次动态复现确认,同一份属性布局、同一个 nil 参数,在字符串和缓冲区路径上 匹配到了不同区间。

修复方向:

使用不可与合法属性值冲突的 sentinel 表示“VALUE 未提供/任意值”,例如内部 tp--any-value;公共 API 可以通过显式 keyword 或拆分入口表达。所有对象路径 应先产生同一种规范化 match 结构,再决定是否移动 point。

6.6 TP-A05字符串子属性删除留下 nil 键

状态:已确认。

字符串子属性删除路径会用 plist-put 把父属性重建为 nil再以 set-text-properties 写回,因此保留属性键、只把值设为 nil缓冲区路径则会 真正删除属性键。

本次复现中,同样的子属性删除后:

  • 字符串 tp-member 返回 (face nil)
  • 缓冲区 tp-member 返回 nil。

相关实现位于 tp-ops.el

修复方向:

先定义子属性删除后父属性为空时的唯一契约:

  • 若父属性没有剩余有效内容,应移除父键;
  • 若业务确实需要显式 nil应由调用者明确请求而不是删除操作偶然产生。

字符串和缓冲区必须使用同一个纯函数计算“旧值 → 新值/删除标记”,然后各自 只负责写回。

6.7 TP-A06NOERROR 吞掉层内部执行错误

状态:已确认。

tp-put-layerNOERROR 路径使用宽泛的 condition-case nil ... (error ...)。本次定义一个解析成功但求值时主动报错的 参数化层后,NOERROR=t 返回 nil内部的真实错误也被吞掉。

NOERROR 合理的语义应当仅是:

找不到指定层/无法解析用户给出的层名时不报错。

它不应当吞掉:

  • 参数化层 body 的 bug
  • 响应式计算错误;
  • 非法属性结构;
  • 栈编解码不变量破坏;
  • 任意其他内部异常。

修复方向:

不要围住整个执行路径捕获 error。先进行可返回“not found”的窄解析再让后续 错误自然传播。

6.8 TP-A07命名层有两种不兼容的应用语义

状态:已确认。

直接属性操作中的层名解析:

(tp-set object 'my-layer ...)

通常展开为该层的属性,但不保存 tp-name (tp-layer.el)。因此它是“一次性模板展开”。

层栈操作:

(tp-push-layer object 'my-layer ...)
(tp-put-layer object ... 'my-layer ...)

会保存 tp-nametp-layers (tp-layer.el),因此是“受管理实例”。

这两种行为各自都可以合理,但使用同一个“应用层”词汇会让用户自然期待:

  • 后续可按层名查询或删除;
  • 层重定义会更新已应用区域;
  • 响应式系统能找到该层;
  • 直接设置与 push 只差栈位置。

实际并非如此。

修复方向:

正式命名两个概念:

  1. 展开模板:把层定义解析成普通 plist不保留身份不参与生命周期
  2. 挂载层实例:保存身份、进入层栈、可刷新、可按名操作。

关键不是增加包装函数,而是让文档、函数名、返回值和测试始终使用同一词汇。

6.9 TP-A08修改性、坐标和返回值没有统一模型

状态:已确认。

修改性

tp-set/reset/add

  • 整串字符串形式返回新字符串;
  • 字符串 region 形式原地修改;
  • 缓冲区原地修改。

层栈的大多数字符串形式又是原地修改 (tp-stack.el)。

这意味着仅从函数名或 OBJECT 类型无法判断是否修改原对象,还必须记住调用形式。

返回值

当前常见返回值包括:

  • 新字符串;
  • 原字符串;
  • (START . END)
  • 被修改的 property run 数量;
  • prop-match
  • (START END VALUE) 列表;
  • nil
  • 层栈查询中的 union、最大深度、首个 top。

其中“property run 数量”不是稳定业务语义,因为无关属性边界也能改变 run 数量。

坐标

字符串通常为 0-based、缓冲区为 1-based这是原生约定本身合理tp-intervals 的缓冲区结果默认又改为相对 START 的 0-based offset只有 ABSOLUTE 才返回原生坐标。一个用户从查询结果继续调用 tp-set 时容易发生 off-by-one。

修复方向:

不必强行让字符串和缓冲区使用同一坐标基数,但必须统一:

  • 默认返回 OBJECT 的原生坐标;
  • 相对坐标必须通过显式选项请求;
  • 查询返回值使用同一种结构;
  • 原地修改与复制式转换必须能从入口名称或显式参数判断;
  • 修改函数返回稳定的“是否变化/结果对象”,诊断统计另设调试入口。

6.10 TP-A09文档和实现存在明确矛盾

状态:已确认。

代表性例子:

  1. tp--resolve-props docstring 说符号层名会包含 tp-name,实现却明确传入 nil不包含 tp-name (tp-layer.el)。
  2. define-tp 的当前使用示例仍声称直接 tp-set 会得到 tp-name,与 README 和当前实现相反。
  3. tp-search-map docstring 说字符串长度不同时 会截断/部分替换,但实际实现 tp--replace-match-text 会明确报错。
  4. README 宣称“统一 API 参数规范”,但同一文档同时记录了字符串复制/原地 修改和搜索结果结构的差异。

修复方向:

先写机器可验证的语义表,再从该表更新 README 与 docstring。避免继续分别修补 中英文文档和函数说明而没有共同规范。

6.11 TP-A10隐藏层状态会静默覆盖外部直接属性编辑

状态:已确认,且内部 docstring 已明确说明。

当任意层处于 hidden 状态时,tp-layers 保存完整层栈,字符的其他直接属性只是 临时渲染表面。栈编解码文档明确说明,期间发生的原生或 tp 直接属性编辑会在 下一次 stack operation 时被丢弃 (tp-layer.el)。

本次最小复现:

  1. push 一个 face=bold 的 managed layer
  2. hide 该层;
  3. 直接写入 help-echo="external"
  4. show 该层。

show 前:

(help-echo "external"
 tp-layers ((tp-hidden t face bold tp-name audit-hidden)))

show 后:

(face bold tp-name audit-hidden)

help-echo 无提示地消失。这不是单纯的内部实现细节,而是用户数据所有权冲突, 应当与 correctness 缺陷一起处理,不能推迟到新增高级层功能之后。

修复方向:

阶段 0 就必须选定默认规则。最小且安全的默认是managed hidden range 检测到 无法归属的直接属性变化时,在下一次栈写入前明确报出冲突;若决定采用 “收编为匿名层”或“明确覆盖”,也必须由公开契约和回归测试证明,不能静默决定。

6.12 TP-A11业务计算与 observer 的错误边界混在一起

状态:源码确认,具体产品策略待定。

当前:

  • transform 出错后 message 并返回原文本 (tp-ops.el)
  • initial compute 出错后 message 并跳过赋值 (tp-reactive.el)
  • watcher 出错后 message 并继续 (tp-reactive.el)。

三者不应使用同一策略:

  • transform/compute 决定业务输出,失败后 fallback 或跳过会留下貌似有效、实际 错误或过期的文本,应默认传播到外层错误边界;
  • watcher 如果只是 observer隔离单个 callback 可以合理,但必须留下可查询的 结构化失败,而不只是易被忽略的 minibuffer message。

修复方向:

先按“计算结果所有者”和“副作用观察者”分类,再收敛错误边界。不要为每个函数 新增一个静默选项;默认让业务计算失败,顶层批量渲染可以汇总多个错误。

6.13 TP-A12响应式注册表依赖入口完整性

状态:源码明确承认。

tp-reactive-layer-buffers 已记录:

插入一个已经带 tp-name 的 propertized string 会绕过 buffer operation 的 登记路径。

当前补救是:

  • unknown 时进行学习性全 buffer scan
  • 用户调用 tp-reactive-track-buffer 手工登记。

这在“高层工具箱”定位下可以接受,但在“所有文本属性操作替代层”定位下不够: 用户可以通过原生 insert、substring、kill/yank、间接缓冲区等多种路径让属性 进入缓冲区,注册表无法假设所有变化都经过 tp。

扩展方向:

提供一个明确的 managed mount/attach 生命周期;不要尝试监听所有原生属性变化。 对于外部插入的属性,提供显式 track/attach 和可选的 after-change 集成,并在 文档中说明成本。

6.14 TP-A13测试数量多但不能证明语义完备

状态:已确认。

现有 604 个 ERT 和 92 个 doctest 对固定回归很有价值,但缺少:

  • 与原生函数的等价性测试;
  • 随机生成属性 interval 的 property-based 测试;
  • nil、缺失、默认值、category、alias 的组合;
  • 字符串/缓冲区同构场景;
  • undo、modified、read-only、hook、stickiness
  • indirect buffer 和 narrowing
  • 将大缓冲区、碎片 interval、深层栈、响应式 fan-out 的性能基准固化为 CI 阈值;
  • 主要交互命令的 call-interactively 路径;
  • 调色板/主题切换后的动态视觉验证。

tp-run-shuffled.el 只是改变测试顺序,不会生成新的输入状态。它能发现全局状态 泄漏,但不能替代属性状态空间测试。


7. API 语义专项分析

7.1 tp-set 的名称容易与原生 set-text-properties 发生反向联想

原生:

  • put-text-property:单键覆盖;
  • add-text-properties:添加/覆盖给定键;
  • set-text-properties:完整替换 plist。

tp

  • tp-set:部分覆盖;
  • tp-reset:完整替换;
  • tp-add:深度/face 合并。

tp 内部体系是自洽的,但熟悉 Emacs 的用户看到 set 往往会联想到“完整替换”。 无需为了原生名字机械改 API但应在语义规范第一屏给出映射表并让返回值也 尽量接近相应原生操作:

用户意图 tp 当前入口 最接近的原生概念
覆盖指定键 tp-set add-text-properties / 多次 put-text-property
替换全部属性 tp-reset set-text-properties
按自定义规则合并 tp-add add-face-text-property + 自定义 merge

7.2 过载解析器降低了可预测性

tp--parse-args 支持多种调用形式,并根据:

  • 第一个参数是 string 还是 number
  • 第二个 symbol 当前是否已注册为层;
  • 第三个参数是否非 nil
  • 余下参数是否看似 object

推断用户意图。

这使调用含义依赖全局层注册状态。例如同一个 symbol 在定义为层前后可能进入 不同解析分支。参数化层还允许 flat、wrapped args 与 extra plist 组合。

这种便利适合 REPL但不适合作为“完整替代层”的唯一规范入口。

建议:

  • 保留便利调用作为外观层;
  • 内部和正式规范使用单一的 canonical request
  • 在解析完成后立刻得到明确的 object/start/end/operation/properties/mutation 语义,后续模块不再重新猜测;
  • 错误应在解析边界一次性报告,不在深层以 nil 继续。

这不要求新增多个文件,也不要求引入新依赖;一个小型、明确的数据结构或参数 规范即可。

7.3 查询 API 应区分四种问题

当前 tp-attp-membertp-gettp-plisttp-search 混合回答了不同问题:

  1. 某位置的全部直接属性是什么?
  2. 某直接属性键是否存在?
  3. 某属性的有效值是什么?
  4. 哪些连续区域满足某个 predicate

建议把这四个问题写成正式语义,再决定是否保留现有函数名。尤其:

  • tp-get 不应过滤合法 nil
  • tp-member 应只回答 presence不顺带承担范围读取
  • tp-plist 的“跨区间 last-value-wins 汇总”应明确标成 summary而不是区域的 属性真相;
  • 搜索结果应包含 range、value并可选包含 source不依赖对象类型改变结构。

7.4 层栈不是原生属性的“自然叠加”

当前层栈只把顶层可见属性渲染到字符的直接 plist下层放入 tp-layers 存储。 这意味着它更接近图像编辑器的图层栈,而不是 CSS cascade 或多个独立属性来源 的逐键组合。

例如:

  • 顶层只含 face
  • 下层含 help-echo

当前“只显示顶层”模型下,下层 help-echo 不一定继续生效。用户若把“属性层” 理解为“每个键按层优先级组合”,会得到不同预期。

建议正式命名两种可能模型:

  1. exclusive layer:只有最高可见层的整个 plist 生效;当前模型;
  2. compositional layer:每个属性键独立按优先级求值。

无需立即实现第二种,但必须在文档中把第一种说清。若未来扩展第二种,也应作为 显式模式,不能悄悄改变现有栈语义。

7.5 区域查询的标量返回值存在信息损失

tp-layer-list 返回区域中出现过的层名 union tp-layer-count 返回所有 run 的最大深度; tp-layer-top 返回第一个带名字的 top。

这些定义不是错误,但函数名看起来像在描述“整个区域的统一状态”。当区域内部 异质时,调用者无法从标量结果判断:

  • 每个 run 是否相同;
  • 某层覆盖全部区域还是只出现一次;
  • top 是否统一;
  • count 的分布是什么。

建议保留便利标量,同时提供或强化 run-aware 查询作为权威入口。标量 docstring 中应直接出现 union/max/first而不是只写“in region”。

7.6 错误策略应只有两个边界

合理的错误边界:

  1. 参数解析/公共命令边界:把非法用户输入转成清晰错误;
  2. 可选的用户 callback 边界:说明 callback 失败是否终止渲染。

当前策略混合了:

  • transform 出错后 message 并退回原文本 (tp-ops.el)
  • watcher 出错后 message 并继续 (tp-reactive.el)
  • initial compute 出错后跳过 (tp-reactive.el)
  • NOERROR 可能吞任意内部错误;
  • 普通参数错误则向上传播。

建议分别规定:

  • pure computation/transform 失败:默认传播,或由顶层统一汇总;
  • watcher 作为副作用 observer可以隔离但必须记录结构化失败
  • “not found” 使用普通返回值,不用异常;
  • 内部不变量错误永不被 NOERROR 吞掉。

7.7 tp-text 跨越了属性、文本替换与响应式状态三个领域

tp-text 看似一个属性键,实际上可能:

  • 替换真实字符串/缓冲区文本;
  • 合并新字符串携带的属性;
  • 保留旧范围属性;
  • 执行 transform
  • 识别 layer identity
  • 更新响应式变量;
  • 触发最小差异编辑。

它不是普通 text property而是一条命令协议。把它与任意 plist 键放在同一个 解析和合并通道中,容易造成 position 0 采样、属性所有权和重复刷新等问题。

建议将 tp-text 明确定义为“文本 replacement directive”

  • 先计算 replacement text
  • 再按 interval 计算其内嵌属性;
  • 再执行文本修改;
  • 最后应用 layer/props
  • 不把 directive 自身持久化为普通文本属性。

公开便利语法可以不变,但内部状态机必须与普通 property merge 分离。


8. 架构与状态所有权建议

8.1 保留现有模块,先确立三个语义域

不建议为了本次问题再拆一批 utils/adapter 文件。现有模块足以承载下面三个 清晰语义域:

域 1原生属性 façade

负责:

  • 字符串/缓冲区直接属性读取;
  • 区间增删改;
  • 搜索与 change boundary
  • 原生坐标、修改、undo/read-only 语义;
  • category/default/alias/overlay-aware 的显式查询模式。

它不拥有层身份。

域 2受管理层实例

负责:

  • layer identity
  • stack entry
  • hide/show/move/merge
  • old/new layer contribution
  • mount/attach/detach
  • 响应式依赖和重渲染。

它不应把直接外部属性偷偷吸收到某个层,也不应在下次 stack operation 时静默 丢弃用户不知道属于谁的属性。

域 3上层表现组件

负责:

  • palette
  • builtin layers
  • 主题感知;
  • UI 辅助。

它可以使用前两个域,但不应让核心包无条件承担全部上层状态和加载成本。

8.2 明确直接属性与 managed layer 的冲突规则

当前 stack codec 文档已经说明:存在隐藏层时,之后的直接原生/tp 属性编辑可能 在下次栈操作中被丢弃 (tp-layer.el)。

这是重要的用户数据所有权问题,不能只放在内部 docstring。

应选择并文档化一种策略:

  1. 严格模式(推荐默认)managed range 上发现无法归属的外部直接修改时, 栈操作报出冲突;
  2. adopt 模式:把外部直接属性采集成一个显式匿名层;
  3. overwrite 模式:明确允许栈状态覆盖外部直接修改。

关键是不能静默决定。

8.3 层刷新必须做完整 entry 替换

层实例应保存:

  • identity
  • 当前定义或参数;
  • 当前解析后的完整 props
  • hidden 状态;
  • 必要时的实例参数/版本。

重定义/响应式更新时,先生成新 entry再用新 entry 替换旧 entry最后从完整 stack 重新计算可见属性。这样自然解决旧键残留,无需猜测哪些直接属性属于旧层。

8.4 不要重新实现 Emacs 属性引擎

完整替代的正确方向不是自己模拟:

  • undo
  • stickiness
  • special property behavior
  • overlay priority
  • font-lock
  • field motion。

这些应继续交给 Emacs。tp 的职责是:

  • 提供完整、显式、可组合的 façade
  • 不破坏原生副作用;
  • 对不支持或不建模的部分给出逃生口;
  • 用等价性测试证明委托正确。

9. 性能与可扩展性分析

9.1 当前值得保留的优化

  • layer → buffer 注册表避免每次都扫描 buffer-list
  • unknown 状态与 known-empty 分开;
  • kill-buffer 清理;
  • property interval 遍历;
  • tp-text 更新尝试做最小文本差异;
  • 批量响应式刷新;
  • 避免相同值重复 put-text-property

9.2 主要性能风险

以下风险已有可复现 benchmark 基线,但仍不应把单机耗时直接当成发布阈值:

场景 风险来源 已记录指标
大缓冲区、高 interval 碎片 多次 next-property-change 与 plist 复制 large-textfragmented rows
层多且频繁 move/hide/show 每 run 解码、重建完整 stack stack-depth rows
响应式 fan-out watcher × layer × buffer reactive-fanout rows
unknown layer buffer-list 学习性扫描 尚未单独拆出
tp-text 长文本变化 差异计算、删除插入、marker/undo large-text rows
匿名层 注册表增长和 GC 扫描 尚未单独拆出
palette/theme 重新解析颜色和重渲染 theme-managed-refresh rows

make benchmark 在 Emacs 30.2 上完成固定 seeds 1742747555 以及可复现 generated seed 8675309。完整记录见 docs/BENCHMARKS.md。 代表性 seed 42 结果:

Scenario Requested / actual Operations Scanned Changed Refreshed Elapsed (s)
large text 100,000 2 100,000 100,000 0 0.000302
large text 1,000,000 2 1,000,000 1,000,000 0 0.000318
fragmented intervals 1,000 1 1,000 500 0 0.047154
fragmented intervals 10,000 1 10,000 5,000 0 0.514766
fragmented intervals 50,000 1 50,000 25,000 0 2.769766
stack depth 1 2 2,000 2,000 0 0.000892
stack depth 5 6 2,000 2,000 0 0.003121
stack depth 20 21 2,000 2,000 0 0.012660
stack depth 50 51 2,000 2,000 0 0.039089
reactive fan-out 1 / 1 1 1 1 1 0.001139
reactive fan-out 10 / 10 1 10 10 10 0.008153
reactive fan-out 100 / 100 1 100 100 100 0.064352
reactive fan-out 500 / 200 1 200 200 200 0.127140
theme refresh 1 / 1 1 1,000 0 1 0.001691

9.3 返回“修改 run 数”会诱导错误优化

run 数受无关属性边界影响,既不是修改字符数,也不是修改业务对象数。如果公开 返回这个值,用户可能把它当成稳定统计或用于控制流。

建议:

  • 公开返回 changed-p 或结果对象;
  • debug/benchmark API 可返回 runs-visited/runs-written/chars-covered
  • 不把实现碎片结构暴露成业务保证。

9.4 dash 依赖可以作为低优先级简化项

当前 dash 主要用于少量 list 操作,很多可以由 seq/cl-lib 表达。移除依赖 可能降低安装摩擦,但不是当前语义问题的根因。

只有在:

  • 能减少公开安装复杂度;
  • 不引入自制 helper 堆栈;
  • 完整测试仍通过;

时才值得处理。优先级应低于 correctness 和 API contract。


10. 测试策略升级

10.1 第一层:为已确认问题补红色回归

必须先加入:

  1. tp-text 初次应用保留多个内嵌 property interval
  2. 字符串与缓冲区各一组;
  3. 显式 nil 覆盖内嵌非 nil
  4. 静态层重定义触发 managed region 刷新;
  5. 已触发刷新时删除旧层属性键;
  6. NOERROR 只吞 unresolved layer不吞 layer body error
  7. 字符串和缓冲区子属性删除得到相同 presence
  8. 精确搜索 nil 与 wildcard 是两种调用;
  9. hidden managed range 上的外部直接编辑不会静默丢失;
  10. transform/compute 与 watcher 分别遵守其错误边界;
  11. search-map docstring 与实际长度规则一致。

10.2 第二层:原生等价性测试

对 façade 中承诺等价的操作,使用同一随机输入分别执行:

native primitive
vs.
tp canonical API

比较:

  • 文本内容;
  • equal-including-properties
  • 每个 change boundary
  • property presence 与 value
  • buffer modified
  • undo 后内容和属性;
  • point/marker
  • 错误类型。

建议覆盖:

  • 空区间、单字符、对象末尾;
  • 相邻同值/不同值 interval
  • nil 值与缺失;
  • 多种 Lisp 值;
  • narrowing
  • read-only
  • sticky boundaries
  • category/default/alias
  • indirect buffer。

10.3 第三层:状态机测试 managed layers

随机生成操作序列:

push → put → hide → redefine → show → move → external edit
→ reactive update → delete → flatten

每一步验证不变量:

  • 每个 stack entry 身份唯一/顺序正确;
  • hidden entry 不泄漏到 rendered plist
  • show 后得到最新定义;
  • 删除层不会删除其他来源属性;
  • encode/decode round-trip
  • 同一操作重复执行幂等;
  • 不相关区域不变化。

10.4 第四层:性能回归

性能基准不应只给一次绝对耗时。建议固定:

  • Emacs 主版本;
  • 文本长度;
  • property run 数;
  • 层数;
  • buffer 数;
  • 响应式依赖 fan-out
  • 重复次数和 GC 策略。

记录斜率和阈值,例如:

  • 10 倍 run 数不应出现 100 倍增长;
  • 更新已知单 buffer 层时不得扫描全部 buffer
  • 无变化刷新不得写入属性或改变 modified flag。

10.5 交互与视觉验证

为交互命令增加少量 call-interactively 路径,验证 prefix、region、current buffer 和错误消息。palette/display/theme 相关能力应增加 GUI Emacs 的可重复截图或状态 检查,尤其是主题切换后已应用层是否刷新。


11. 分阶段实施路线

阶段 0冻结目标语义不新增大功能

产出一份短小、可测试的 API-SEMANTICS.md,至少规定:

  • 对象和坐标;
  • 字符串复制/原地修改;
  • buffer modified/undo/read-only
  • presence 与 nil
  • wildcard sentinel
  • 返回结构;
  • 直接模板展开 vs managed mount
  • 错误传播;
  • tp-text replacement
  • 外部属性与层栈冲突。

停止条件:每个公开核心函数都能映射到该规范中的一条操作语义。

阶段 1修复 P0/P1 正确性问题

顺序:

  1. 为 TP-A01A06 和 TP-A10 写失败测试;
  2. 合并 tp-text 初次/更新的 per-interval 属性算法;
  3. 全库用 presence-aware 判断替换真值判断;
  4. 引入 internal wildcard sentinel
  5. 层 entry 使用完整替换;
  6. 收窄 NOERROR
  7. 落实 managed range 外部编辑的唯一默认冲突策略;
  8. 区分业务计算失败与 observer 失败;
  9. 同步 docstring、README 和 doctest。

停止条件:回归测试全绿,现有 604 ERT/92 doctest 不退化。

阶段 2建立 canonical façade

状态:已完成(内部模型)。公开入口和历史返回值保持兼容。

不要先追求更多便利重载。先保证内部存在唯一规范路径:

request
  = object
  + native range
  + operation
  + properties/value/predicate
  + mutation policy
  + error/read-only policy

现有 tp-set/reset/add/... 可以继续作为外观入口,但都解析到同一规范路径。

停止条件:

  • 字符串与缓冲区的等价场景只在 I/O 边界分支;
  • 查询内部结果结构不再随对象类型改变;公开返回保持历史兼容;
  • 内部 canonical range 使用原生坐标;tp-intervals / tp-intervals-map 的缓冲区 relative 默认是保留的公开兼容例外;
  • “是否原地修改”可从公开调用清楚判断。

阶段 3补齐原生文本属性语义

状态已完成text-only。overlay-aware 字符属性查询见阶段 5。

按价值从高到低:

  1. presence-aware exact nil
  2. next/previous single/all property change
  3. text-property-any / not-all / predicate search 对齐;
  4. direct vs effective lookup
  5. category/default/alias 有效值委托成为明确且经过测试的 lookup 契约;
  6. read-only、modified、undo 和 silent modification 的三种有效策略;
  7. stickiness 与 insert 行为直接委托 Emacs无 tp wrapper
  8. copy/insert/kill/yank 属性保留与过滤直接委托 Emacs无 tp wrapper
  9. narrowing 与 indirect buffer 坐标/共享文本行为通过原生等价性测试。

停止条件:与选定 GNU Emacs 基线的原生等价性测试通过。

阶段 4完善 managed layer 生命周期

状态:已完成。

建议扩展:

  • 显式 managed layer attach/detach
  • 层实例参数和版本;
  • old/new entry 全量替换与参数化 args refresh
  • 已选外部编辑冲突策略在 push/hide/show/merge 全路径保持一致;
  • 单 managed layer 使用 tp-layers 保存 authoritative tp-metadirect rendered/public stack query 不暴露 tp-meta
  • 插入带层字符串后的显式 attach
  • theme generation 与 enable/disable conservative refresh diagnostics
  • registry 诊断与泄漏检查入口;
  • layer transaction 的结构化成功/失败和 rollback 报告;
  • compositional layer 作为可选扩展,未纳入当前完成条件。

停止条件:随机操作序列下 stack/render/registry 不变量持续成立。

阶段 5可选的字符属性兼容层

状态已完成lookup。overlay lifecycle 明确排除。

若产品确实要使用“所有字符属性操作”定位,再增加:

  • text-only 与 overlay-aware lookup 模式;
  • source-aware resultoverlay 获胜时报告 :overlay 与 overlay identity
  • overlay priority/change boundary 委托 Emacs get-char-property-and-overlay
  • fields、buttons、display 等特殊属性行为委托 Emacs不提供高层 wrapper。

不建议把 overlay 的创建/移动/删除硬塞进现有文本属性函数;应保持清晰边界。

停止条件:文档明确哪些语义由 tp 提供,哪些委托 Emacs哪些刻意排除。


12. 可拓展方向

以下扩展建立在前述 correctness 与 contract 完成之后。

12.1 语义查询层

可提供:

  • direct/effective/source-aware property query
  • overlay-aware 可选模式;
  • 区域一致性判断,例如“整个区域是否同值”;
  • run-aware layer snapshot
  • 属性来源解释器:某个最终 face 来自哪个层/category/overlay。

这会比再增加几个重载 setter 更能支持调试和复杂 UI。

12.2 compositional layer

在现有 exclusive stack 之外,可增加按属性键独立合成的模式:

top layer:    face
middle layer: help-echo
bottom layer: keymap

最终三个键都可生效,同键才按优先级覆盖。它适合:

  • 语法 + 诊断 + 交互提示并存;
  • hover/click/face 分属不同业务来源;
  • 响应式状态只更新自己拥有的键。

必须通过显式 layer mode 启用,以免改变现有语义。

12.3 transaction 与冲突检测

对复杂 UI允许在一次事务中

  • 读取当前 stack version
  • 修改多个层;
  • 一次重建;
  • 收集 changed ranges
  • 失败时不留下半更新状态。

冲突检测可发现原生外部修改,避免下次 stack operation 静默覆盖。

12.4 增量索引和诊断

暴露只读诊断:

  • 某层登记了哪些 buffer
  • 某 buffer 有哪些 managed layer
  • 某层依赖哪些变量;
  • 最近一次更新访问/写入多少 run
  • 未追踪但扫描发现了哪些层;
  • 匿名层为何仍存活。

这能把响应式问题从“猜”变为可观察。

12.5 theme-aware 生命周期

调色板当前在解析颜色时读取 frame 的主题类型 (tp-parse-color),但静态已应用结果不会自然随 主题变化。 可以引入:

  • theme generation/version
  • enable/disable-theme hook 后批量失效;
  • 只重渲染真正依赖 palette 的层;
  • theme change 的视觉回归。

12.6 与 font-lock/jit-lock 的协作

对于大缓冲区,主动给整段文本写 face 不一定是最佳方案。可探索:

  • 层定义生成 font-lock rule
  • jit-lock 按可见范围物化;
  • tp 管理来源身份和规则Emacs 管理惰性字体化;
  • 明确 facefont-lock-face 的优先级和清理策略。

这适合语法、诊断等长文档场景,但需要单独的小型验证,不应直接扩展全库。

12.7 可选核心加载

require 'tp 当前加载 palette/builtins。未来若安装时间或最小依赖成为真实问题 可以提供:

  • 核心 façade
  • managed layers/reactive
  • palette/builtins

三个明确 feature 入口。只有在有启动性能或依赖数据支持时再做,不为拆分而拆分。


13. 推荐的产品定位与文案

当前版本推荐

tp 为 GNU Emacs 字符串和缓冲区提供高层文本属性操作 API并在原生属性之上 增加命名层栈、响应式属性、批量匹配和调色板能力。它覆盖常见属性工作流,但 category/default/alias、stickiness、undo、yank、special properties 和 overlays 的底层行为仍由 Emacs 执行,其中部分尚未成为 tp 的显式 API 契约。

达到阶段 3 后可使用

tp 是 GNU Emacs 文本属性操作的完整高层 façade常见原生读取、写入、搜索、 复制和编辑语义均有明确映射,同时可选择受管理层栈和响应式扩展。

不建议使用

tp 完全替代 Emacs 的所有文本属性和字符属性系统。

除非 overlay-aware、category/default/alias、stickiness、undo/yank 等都有明确 覆盖矩阵和等价性测试,否则这句话无法被验证。


14. “可以宣称完整替代”的验收清单

只有同时满足以下条件,才建议宣称“完整文本属性操作替代层”:

核心契约

  • 字符串/缓冲区的修改性在公开入口上可预测;
  • 默认坐标是对象原生坐标(tp-intervals / tp-intervals-map 的 relative 默认为公开兼容例外);
  • 所有 text-only 查询区分缺失与显式 nil
  • wildcard 与 VALUE=nil 不复用同一个参数状态;
  • 内部查询结果结构不随对象类型改变;部分 public returns 保持历史兼容;
  • Stage 1 错误传播与 NOERROR 范围有规范;
  • read-only/modified/undo 的三种 mutation policy 组合有测试保证。

原生覆盖

  • put/add/set/remove/remove-list/clear 有明确映射;
  • text-properties-at/get-text-property 有明确映射;
  • property change/search 家族有明确映射;
  • category/default/alias 有 direct/effective 区分;
  • get-char-property / get-char-property-and-overlay 查询有明确映射;
  • stickiness 与插入继承有文档和测试;
  • copy/substring/insert/kill/yank 有文档和测试;
  • special properties 明确委托给 Emacs不被 tp 破坏;
  • narrowing/indirect buffer 行为通过测试。

managed layers

  • 直接模板展开和 managed layer 实例名称不同、行为不同;
  • 层重定义能删除旧贡献;
  • hidden/buried layer 始终保存最新 entry
  • 外部直接属性修改不会被静默丢失;
  • 插入 propertized string 后的 attach/track 行为明确;
  • 参数化 mounted layer args/version refresh 通过测试;
  • transaction success/failure rollback 与 diagnostics 通过测试;
  • theme generation diagnostics 通过测试。

工程证据

  • 原生等价性测试覆盖选定 Emacs 版本;
  • 性能基准覆盖大文本、碎片 interval、深层栈和 fan-out结果为 advisory baseline不是发布阈值
  • 中英文 README、docstring、doctest 来自同一语义规范;
  • CI 全绿且 warning 为零。

15. 最终优先级

立即处理

  1. TP-A01tp-text 初次多 interval 属性扩散;
  2. TP-A10阻止 managed hidden range 静默丢弃外部直接属性;
  3. TP-A02静态层重定义与层刷新 old/new 所有权;
  4. TP-A03/A04/A05presence、nil、wildcard、删除状态统一
  5. TP-A06收窄 NOERROR
  6. TP-A11收敛业务计算与 observer 错误边界;
  7. 修复已确认 docstring/实现矛盾。

下一版本的设计重点

  1. CI/WERROR/full-suite release evidence 汇总;
  2. 可选 compositional layer 的小型验证;
  3. theme refresh 的真实依赖定向优化;
  4. 将 benchmark advisory baseline 转为 CI 阈值;
  5. overlay lifecycle 是否继续排除的长期产品边界复审。

在语义收敛后再扩展

  1. compositional layers
  2. transaction/冲突检测;
  3. overlay-aware/source-aware 查询;
  4. theme-aware 刷新;
  5. font-lock/jit-lock 集成;
  6. 性能与诊断工具;
  7. 可选核心加载。

16. 验证记录

本次审计在 Emacs 30.2 上完成以下验证:

compile-allWERROR=t  通过
ERT                      604/604 通过
doctest                  92/92 通过
benchmark                通过fixed seeds=1/7/42/747555generated seed=8675309
shuffled ERT             604/604 通过seed=747555

测试使用仓库 Makefile 和单独加载的 dash 2.20.0。所有动态问题复现都在同一代码 快照 a65d799 上执行。

本报告的结论边界是:

  • “已有测试全绿”是已确认事实;
  • TP-A01A06、TP-A10 和 TP-A11 已有回归测试覆盖;
  • 性能部分记录 advisory baseline没有把单机耗时声明为发布阈值
  • overlay 是否进入产品范围是定位决策,但 overlay-aware 查询是任何“字符属性 完整替代”声明不可回避的边界。

17. 官方参考

18. 最小动态复现

以下表达式均在仓库根目录、Emacs 30.2、dash 2.20.0、快照 a65d799 上复核。 通用命令为:

EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
DASH=/path/to/dash-2.20.0
"$EMACS" -Q --batch -L . -L "$DASH" -l tp.el --eval '<EXPRESSION>'

将下面每个完整表达式作为 --eval 的参数即可。使用其他受支持 Emacs 版本时, 应同时记录版本和输出。

18.1 TP-A01初次 tp-text 多 interval 污染

(let* ((payload
        (concat
         (propertize "AB" (quote face) (quote bold))
         (propertize "CD" (quote face) (quote italic))))
       (result (tp-set "xxxx" (quote tp-text) payload)))
  (princ
   (format "string: p0=%S p2=%S\n"
           (get-text-property 0 (quote face) result)
           (get-text-property 2 (quote face) result)))
  (with-temp-buffer
    (insert "xxxx")
    (tp-set 1 5 (list (quote tp-text) payload))
    (princ
     (format "buffer: p1=%S p3=%S\n"
             (get-text-property 1 (quote face))
             (get-text-property 3 (quote face))))))

实际:

string: p0=bold p2=(bold italic)
buffer: p1=bold p3=bold

预期后半段只为 italic

18.2 TP-A02-A静态层重定义不刷新

(progn
  (tp-layer-reset)
  (define-tp audit-static ()
    (quote (face bold help-echo "old")))
  (with-temp-buffer
    (insert "abc")
    (tp-push-layer 1 4 (quote audit-static))
    (define-tp audit-static ()
      (quote (face italic help-echo "new")))
    (princ
     (format "face=%S help=%S stack=%S\n"
             (get-text-property 1 (quote face))
             (get-text-property 1 (quote help-echo))
             (tp-layer-stack-at 1)))))

实际:

face=bold help="old"
stack=((audit-static face bold help-echo "old"))

18.3 TP-A02-B已触发 refresh 仍残留旧键

(progn
  (tp-layer-reset)
  (defvar audit-fg "red")
  (defvar audit-bg "blue")
  (define-tp audit-reactive ()
    :props (quote
            (face (:foreground $audit-fg)
                  help-echo "old")))
  (with-temp-buffer
    (insert "abc")
    (tp-push-layer 1 4 (quote audit-reactive))
    (define-tp audit-reactive ()
      :props (quote (face (:background $audit-bg))))
    (princ
     (format "face=%S help=%S stack=%S\n"
             (get-text-property 1 (quote face))
             (get-text-property 1 (quote help-echo))
             (tp-layer-stack-at 1)))))

实际:

face=(:background "blue") help="old"
stack=((audit-reactive face (:background "blue") help-echo "old"))

预期 stack 和直接属性中都不再存在 help-echo

18.4 TP-A03显式 nil 被内嵌值覆盖

(let* ((payload
        (propertize "X" (quote custom) (quote embedded)))
       (result
        (tp-set "x"
                (quote tp-text) payload
                (quote custom) nil)))
  (princ
   (format "member=%S props=%S\n"
           (tp-member 0 (quote custom) result)
           (text-properties-at 0 result))))

实际:

member=(custom embedded)
props=(custom embedded tp-text #("X" 0 1 (custom embedded)))

预期 member=(custom nil)

18.5 TP-A04nil 搜索在字符串与缓冲区中不同

(let ((s (copy-sequence "abc")))
  (put-text-property 0 1 (quote p) (quote x) s)
  (put-text-property 2 3 (quote p) nil s)
  (princ
   (format "string=%S\n"
           (tp-forward (quote p) nil s 1)))
  (with-temp-buffer
    (insert "abc")
    (put-text-property 1 2 (quote p) (quote x))
    (put-text-property 3 4 (quote p) nil)
    (goto-char 1)
    (let ((match
           (tp-forward (quote p) nil (current-buffer) 1)))
      (princ
       (format
        "buffer=%S\n"
        (and match
             (list (prop-match-beginning match)
                   (prop-match-end match)
                   (prop-match-value match))))))))

实际:

string=((0 1 x))
buffer=(2 4 nil)

字符串路径把 nil 当作 wildcard缓冲区路径按 nil 值搜索,并把“缺失”和 “显式 nil”的相邻范围合并为一个有效值为 nil 的 match。

18.6 TP-A05子属性删除后的 presence 不同

(let* ((s
        (propertize "x"
                    (quote face)
                    (quote (:underline t))))
       (result
        (tp-remove s (quote face) :underline)))
  (princ
   (format "string=%S props=%S\n"
           (tp-member 0 (quote face) result)
           (text-properties-at 0 result)))
  (with-temp-buffer
    (insert
     (propertize "x"
                 (quote face)
                 (quote (:underline t))))
    (tp-remove 1 2 (quote (face :underline)))
    (princ
     (format "buffer=%S props=%S\n"
             (tp-member 1 (quote face))
             (text-properties-at 1)))))

实际:

string=(face nil) props=(face nil)
buffer=nil props=nil

18.7 TP-A06NOERROR 吞掉层 body 错误

(progn
  (define-tp audit-boom (x)
    (error "audit boom %S" x))
  (let ((s (copy-sequence "x")))
    (princ
     (format "result=%S props=%S\n"
             (tp-push-layer
              s (quote (audit-boom 1)) t)
             (text-properties-at 0 s)))))

实际:

result=nil props=nil

关闭 NOERROR 时同一层 body 会正常抛出 audit boom 1

18.8 TP-A10外部直接属性被后续栈操作丢弃

(progn
  (tp-layer-reset)
  (define-tp audit-hidden ()
    (quote (face bold)))
  (let ((s (copy-sequence "x")))
    (tp-push-layer s (quote audit-hidden))
    (tp-hide-layer s (quote audit-hidden))
    (put-text-property
     0 1 (quote help-echo) "external" s)
    (princ
     (format "before=%S\n"
             (text-properties-at 0 s)))
    (tp-show-layer s (quote audit-hidden))
    (princ
     (format "after=%S\n"
             (text-properties-at 0 s)))))

实际:

before=(help-echo "external"
        tp-layers ((tp-hidden t face bold tp-name audit-hidden)))
after=(face bold tp-name audit-hidden)

help-echotp-show-layer 后无提示消失。