Replace the legacy managed layer renderer with one independent retained/reactive text runtime. TP now owns exact dependencies, stable objects, marker-backed mounts, property contribution composition, atomic publication, rollback, and direct text-property facades without ECSS or Ebox dependencies.\n\nBREAKING CHANGE: remove tp-render, tp-stack, scan-driven managed layers, inline runtime metadata, TP-owned CSS cascade APIs, and dollar-variable declarations.\n\nVerified: 290/290 ERT, shuffled 290/290 (seed 20260806), 8/8 doctests, WERROR compile-all, checkdoc, package-lint, diff-check, and isolated TP-only load.
70 KiB
tp 仓库系统审计与“文本属性操作替代层”能力评估
历史 TP 0.3 审计,TP 1.0 已废弃。 本文固定记录提交
a65d799的审计证据和当时完成的 0.3 路线,不描述 TP 1.0 当前架构。下文的 managed layer/stack、tp-render.el、tp-stack.el、tp-text、inlinetp-name/tp-layers/tp-meta、扫描刷新、阶段状态和代码链接均应按历史快照阅读,不应作为现行 API 或实现依据;正文语义保持原样。当前事实见 README、当前架构、API 合同 与 1.0 变更记录。
审计日期:2026-07-28 审计快照:
a65d799(tp 0.3.0) 结论置信度:高 范围:公开 API 语义、核心实现、层栈与响应式设计、Emacs 原生语义覆盖、 测试与 CI、性能风险、文档一致性,以及后续扩展路线。
实施状态(2026-07-28):阶段 0 语义已冻结在 API-SEMANTICS.md;阶段 1 的 TP-A01~A06、TP-A10 与 TP-A11 已按本文路线实现回归测试和修复;阶段 2 canonical façade 已完成为 内部记录/数据流模型,公开返回保持兼容;阶段 3 text-only 原生语义 façade 已完成;阶段 4 managed lifecycle 已完成;阶段 5 overlay-aware 查询已完成, overlay lifecycle 明确排除。下文的问题描述保留为审计快照证据,不代表 修复后工作树的现状。
当前验收结论:按路线编号共有阶段 0~5(6 个阶段;若只统计开发阶段则为 1~5 共 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 直接回答
如果“替代所有文本属性操作”是指:
- 用更统一、易记的接口完成日常的属性设置、查询、删除、批量匹配;
- 同时提供原生 API 没有的命名层、层栈、响应式属性、调色板等高层能力;
那么 tp 已经基本具备成为首选高层工具箱的条件。
如果“替代”是指:
- tp 的每个 API 在字符串与缓冲区上都具有稳定、可预测、统一的语义;
- 可以覆盖或等价映射 GNU Emacs 的全部文本属性读取、写入、边界搜索、 插入继承、分类默认值、撤销、复制/yank、特殊属性行为;
- 进一步覆盖
get-char-property所代表的文本属性与 overlay 联合视图; - 现有用户可以不再理解 Emacs 原生语义而只依赖 tp;
那么答案是 目前不能。
当前最准确的定位应当是:
tp 是建立在 Emacs 原生文本属性之上的高层操作工具箱,并额外提供一套 受管理的命名层、层栈与响应式渲染模型。
不宜在当前版本中把它描述为:
Emacs 全部文本属性/字符属性操作的语义等价替代品。
0.2 为什么还不能称为“完整替代”
主要原因不是函数数量不足,而是以下四个语义问题:
- 公共 API 契约尚未完全收敛。 同一函数在字符串和缓冲区上可能采用不同 的修改方式、坐标、返回类型与搜索结果结构。
- “属性不存在”和“属性存在但值为 nil”没有被全程区分。 这会直接破坏 精确查询、搜索、覆盖和删除语义。
- 原生文本属性模型与 tp 的受管理层模型有两个不同的所有权系统。
tp-set展开命名层后通常不保留身份,而tp-push-layer/tp-put-layer会写入tp-name/tp-layers;二者不能互换。 - 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 明确排除 |
合理的近期目标是先完整达到 B;C 应当被设计成显式兼容层,而不是重新实现 Emacs 的底层属性引擎。
1. 审计方法与证据边界
本报告使用了四类证据:
- 源码审计:逐模块检查公开入口、解析器、属性合并、层栈编解码、响应式 注册与刷新路径。
- 文档交叉检查:比较 README、函数 docstring、架构文档和 CHANGELOG。
- 动态验证:运行完整编译、ERT、doctest、随机顺序测试,并对关键语义 编写最小批处理复现。
- 官方语义基线:以 GNU Emacs Lisp Reference Manual 的 Text Properties、 Examining Properties、 Changing Properties、 Property Search、 Sticky Properties 和 Special 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-name 和 tp-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
原生读取可能涉及:
- 字符上的直接文本属性;
category符号 plist 提供的默认值;default-text-properties;char-property-alias-alist;- 对
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 都必须为下列查询提供不同结果:
- 缺少键;
- 存在键且值为 nil;
- 存在键且值为非 nil;
- 调用者没有传 VALUE,表示匹配任意值。
当前 tp 在若干路径中使用 plist-get 的真值或参数 nil 同时表达多个状态,
这是最需要优先消除的语义根因之一。
4.4 写属性会参与修改、撤销和 hook
GNU Emacs 中,缓冲区文本属性变化通常会:
- 设置 buffer modified;
- 进入 undo;
- 触发相关 modification hooks;
- 受
read-only和inhibit-read-only影响。
with-silent-modifications 可以用于不应污染 modified/undo 的纯属性更新,但它
有明确边界,不能粗暴包住真实文本修改。
tp 当前一些路径尊重原生行为,另一些路径通过
tp-with-current-buffer 无条件绑定
inhibit-read-only。如果目标是原生语义替代,这种“默认强制写入”必须成为
显式策略,而不是辅助宏的隐藏副作用。
4.5 属性会影响未来编辑和交互
front-sticky、rear-nonsticky 与 text-property-default-nonsticky 决定新文本
怎样继承属性;insert-and-inherit 与普通 insert 又不同。
此外,以下属性不是被动数据:
read-only;modification-hooks、insert-in-front-hooks、insert-behind-hooks;invisible、display、composition;keymap、local-map、help-echo;field;cursor-intangible、cursor-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-remove、tp-clear |
部分 | 字符串子属性删除可留下“存在但为 nil”的键 |
| 位置读取 | tp-at、tp-member、tp-lookup |
已达到 Stage 3/5 | overlay lifecycle 不属于 lookup |
| 区间读取 | tp-get、tp-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/search、tp-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
本次批处理复现中:
- 字符串初次应用的后半段同时得到
bold与italic; - 缓冲区初次应用把
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-refresh(tp-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 时,只更新新键,不删除旧键
响应式层重定义会触发 refresh,但
tp--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 不能覆盖内嵌属性
状态:已确认。
(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-member;plist-get 只用于取值。
随后应全库搜索相同模式,而不是只修这一处。
6.5 TP-A04:VALUE=nil 无法表达精确搜索
状态:已确认。
tp-search 的条件是:
(or (null value)
(equal prop-val value))
因此 nil 同时表示:
- 调用者省略 VALUE,匹配“属性存在且任意值”;
- 调用者明确传入 nil,匹配“属性存在且值为 nil”。
两者无法区分。
更严重的是 tp-forward:
- 字符串路径委托给
tp-search,nil 是 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-A06:NOERROR 吞掉层内部执行错误
状态:已确认。
tp-put-layer 的 NOERROR 路径使用宽泛的
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-name 和 tp-layers
(tp-layer.el),因此是“受管理实例”。
这两种行为各自都可以合理,但使用同一个“应用层”词汇会让用户自然期待:
- 后续可按层名查询或删除;
- 层重定义会更新已应用区域;
- 响应式系统能找到该层;
- 直接设置与 push 只差栈位置。
实际并非如此。
修复方向:
正式命名两个概念:
- 展开模板:把层定义解析成普通 plist,不保留身份,不参与生命周期;
- 挂载层实例:保存身份、进入层栈、可刷新、可按名操作。
关键不是增加包装函数,而是让文档、函数名、返回值和测试始终使用同一词汇。
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:文档和实现存在明确矛盾
状态:已确认。
代表性例子:
tp--resolve-propsdocstring 说符号层名会包含tp-name,实现却明确传入 nil,不包含tp-name(tp-layer.el)。define-tp的当前使用示例仍声称直接tp-set会得到tp-name,与 README 和当前实现相反。tp-search-mapdocstring 说字符串长度不同时 会截断/部分替换,但实际实现tp--replace-match-text会明确报错。- README 宣称“统一 API 参数规范”,但同一文档同时记录了字符串复制/原地 修改和搜索结果结构的差异。
修复方向:
先写机器可验证的语义表,再从该表更新 README 与 docstring。避免继续分别修补 中英文文档和函数说明而没有共同规范。
6.11 TP-A10:隐藏层状态会静默覆盖外部直接属性编辑
状态:已确认,且内部 docstring 已明确说明。
当任意层处于 hidden 状态时,tp-layers 保存完整层栈,字符的其他直接属性只是
临时渲染表面。栈编解码文档明确说明,期间发生的原生或 tp 直接属性编辑会在
下一次 stack operation 时被丢弃
(tp-layer.el)。
本次最小复现:
- push 一个
face=bold的 managed layer; - hide 该层;
- 直接写入
help-echo="external"; - 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路径; - 调色板/主题切换后的动态视觉验证。
tests/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-at、tp-member、tp-get、tp-plist、tp-search 混合回答了不同问题:
- 某位置的全部直接属性是什么?
- 某直接属性键是否存在?
- 某属性的有效值是什么?
- 哪些连续区域满足某个 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 不一定继续生效。用户若把“属性层”
理解为“每个键按层优先级组合”,会得到不同预期。
建议正式命名两种可能模型:
- exclusive layer:只有最高可见层的整个 plist 生效;当前模型;
- 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 错误策略应只有两个边界
合理的错误边界:
- 参数解析/公共命令边界:把非法用户输入转成清晰错误;
- 可选的用户 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。
应选择并文档化一种策略:
- 严格模式(推荐默认):managed range 上发现无法归属的外部直接修改时, 栈操作报出冲突;
- adopt 模式:把外部直接属性采集成一个显式匿名层;
- 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-text 与 fragmented 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 1、7、42、747555
以及可复现 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 第一层:为已确认问题补红色回归
必须先加入:
tp-text初次应用保留多个内嵌 property interval;- 字符串与缓冲区各一组;
- 显式 nil 覆盖内嵌非 nil;
- 静态层重定义触发 managed region 刷新;
- 已触发刷新时删除旧层属性键;
NOERROR只吞 unresolved layer,不吞 layer body error;- 字符串和缓冲区子属性删除得到相同 presence;
- 精确搜索 nil 与 wildcard 是两种调用;
- hidden managed range 上的外部直接编辑不会静默丢失;
- transform/compute 与 watcher 分别遵守其错误边界;
- 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 正确性问题
顺序:
- 为 TP-A01~A06 和 TP-A10 写失败测试;
- 合并
tp-text初次/更新的 per-interval 属性算法; - 全库用 presence-aware 判断替换真值判断;
- 引入 internal wildcard sentinel;
- 层 entry 使用完整替换;
- 收窄
NOERROR; - 落实 managed range 外部编辑的唯一默认冲突策略;
- 区分业务计算失败与 observer 失败;
- 同步 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。
按价值从高到低:
- presence-aware exact nil;
- next/previous single/all property change;
text-property-any/not-all/ predicate search 对齐;- direct vs effective lookup;
- category/default/alias 有效值委托成为明确且经过测试的 lookup 契约;
- read-only、modified、undo 和 silent modification 的三种有效策略;
- stickiness 与 insert 行为直接委托 Emacs,无 tp wrapper;
- copy/insert/kill/yank 属性保留与过滤直接委托 Emacs,无 tp wrapper;
- 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保存 authoritativetp-meta,direct 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 result,overlay 获胜时报告
: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 管理惰性字体化;
- 明确
face与font-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. 最终优先级
立即处理
- TP-A01:
tp-text初次多 interval 属性扩散; - TP-A10:阻止 managed hidden range 静默丢弃外部直接属性;
- TP-A02:静态层重定义与层刷新 old/new 所有权;
- TP-A03/A04/A05:presence、nil、wildcard、删除状态统一;
- TP-A06:收窄
NOERROR; - TP-A11:收敛业务计算与 observer 错误边界;
- 修复已确认 docstring/实现矛盾。
下一版本的设计重点
- CI/WERROR/full-suite release evidence 汇总;
- 可选 compositional layer 的小型验证;
- theme refresh 的真实依赖定向优化;
- 将 benchmark advisory baseline 转为 CI 阈值;
- overlay lifecycle 是否继续排除的长期产品边界复审。
在语义收敛后再扩展
- compositional layers;
- transaction/冲突检测;
- overlay-aware/source-aware 查询;
- theme-aware 刷新;
- font-lock/jit-lock 集成;
- 性能与诊断工具;
- 可选核心加载。
16. 验证记录
本次审计在 Emacs 30.2 上完成以下验证:
compile-all(WERROR=t) 通过
ERT 604/604 通过
doctest 92/92 通过
benchmark 通过,fixed seeds=1/7/42/747555,generated seed=8675309
shuffled ERT 604/604 通过,seed=747555
测试使用仓库 Makefile 和单独加载的 dash 2.20.0。所有动态问题复现都在同一代码
快照 a65d799 上执行。
本报告的结论边界是:
- “已有测试全绿”是已确认事实;
- TP-A01~A06、TP-A10 和 TP-A11 已有回归测试覆盖;
- 性能部分记录 advisory baseline,没有把单机耗时声明为发布阈值;
- overlay 是否进入产品范围是定位决策,但 overlay-aware 查询是任何“字符属性 完整替代”声明不可回避的边界。
17. 官方参考
- GNU Emacs Lisp Reference Manual: Text Properties
- Examining Text Properties
- Changing Text Properties
- Text Property Search Functions
- Why Text Properties are not Intervals
- Stickiness of Text Properties
- Properties with Special Meanings
- Fields
- Yanking
- Insertion
- Narrowing
- Indirect Buffers
- Overlay Properties
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-A04:nil 搜索在字符串与缓冲区中不同
(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-A06:NOERROR 吞掉层 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-echo 在 tp-show-layer 后无提示消失。