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.
1900 lines
70 KiB
Markdown
1900 lines
70 KiB
Markdown
# tp 仓库系统审计与“文本属性操作替代层”能力评估
|
||
|
||
> **历史 TP 0.3 审计,TP 1.0 已废弃。** 本文固定记录提交 `a65d799` 的审计证据和当时完成的 0.3 路线,不描述 TP 1.0 当前架构。下文的 managed layer/stack、`tp-render.el`、`tp-stack.el`、`tp-text`、inline `tp-name`/`tp-layers`/`tp-meta`、扫描刷新、阶段状态和代码链接均应按历史快照阅读,不应作为现行 API 或实现依据;正文语义保持原样。当前事实见 [README](../README_CN.md)、[当前架构](ARCHITECTURE.md)、[API 合同](API-SEMANTICS.md) 与 [1.0 变更记录](../CHANGELOG.md)。
|
||
|
||
> 审计日期:2026-07-28
|
||
> 审计快照:`a65d799`(tp 0.3.0)
|
||
> 结论置信度:高
|
||
> 范围:公开 API 语义、核心实现、层栈与响应式设计、Emacs 原生语义覆盖、
|
||
> 测试与 CI、性能风险、文档一致性,以及后续扩展路线。
|
||
|
||
> 实施状态(2026-07-28):阶段 0 语义已冻结在
|
||
> [API-SEMANTICS.md](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 直接回答
|
||
|
||
如果“替代所有文本属性操作”是指:
|
||
|
||
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 节](#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. 审计方法与证据边界
|
||
|
||
本报告使用了四类证据:
|
||
|
||
1. **源码审计**:逐模块检查公开入口、解析器、属性合并、层栈编解码、响应式
|
||
注册与刷新路径。
|
||
2. **文档交叉检查**:比较 README、函数 docstring、架构文档和 CHANGELOG。
|
||
3. **动态验证**:运行完整编译、ERT、doctest、随机顺序测试,并对关键语义
|
||
编写最小批处理复现。
|
||
4. **官方语义基线**:以 GNU Emacs Lisp Reference Manual 的
|
||
[Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Text-Properties.html)、
|
||
[Examining Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Examining-Properties.html)、
|
||
[Changing Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Changing-Properties.html)、
|
||
[Property Search](https://www.gnu.org/software/emacs/manual/html_node/elisp/Property-Search.html)、
|
||
[Sticky Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Sticky-Properties.html)
|
||
和 [Special Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Special-Properties.html)
|
||
为比较基线。
|
||
|
||
报告使用以下证据标记:
|
||
|
||
| 标记 | 含义 |
|
||
| --- | --- |
|
||
| 已确认 | 有源码路径和动态复现,或有明确测试证明 |
|
||
| 高置信推断 | 源码控制流明确,但尚未加入永久回归测试 |
|
||
| 需阈值 | 已有 benchmark 基线,但尚未固化为发布阈值 |
|
||
|
||
本报告没有把“测试全绿”等同于“没有问题”。现有测试主要证明已经写入测试的
|
||
行为;本次发现的问题恰好说明,缺少跨 API 等价性和状态空间测试时,固定回归
|
||
测试无法证明语义完备性。
|
||
|
||
---
|
||
|
||
## 2. 仓库现状
|
||
|
||
### 2.1 模块结构
|
||
|
||
当前代码采用线性模块边界:
|
||
|
||
```text
|
||
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](https://www.gnu.org/software/emacs/manual/html_node/elisp/Not-Intervals.html)。
|
||
|
||
这意味着 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 模型中:
|
||
|
||
```elisp
|
||
(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-only` 和 `inhibit-read-only` 影响。
|
||
|
||
`with-silent-modifications` 可以用于不应污染 modified/undo 的纯属性更新,但它
|
||
有明确边界,不能粗暴包住真实文本修改。
|
||
|
||
tp 当前一些路径尊重原生行为,另一些路径通过
|
||
[`tp-with-current-buffer`](../tp-core.el#L110-L115) 无条件绑定
|
||
`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`](../tp-core.el#L45-L72) 被用于禁止层名与
|
||
原生属性名冲突。这个手工列表:
|
||
|
||
- 不可能天然随 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`](../tp-core.el#L326-L352) 明确只读取字符串
|
||
位置 0 的属性。初次 `tp-text` 处理路径随后把这一结果作为整个替换区域的基础
|
||
属性;虽然部分路径又按 interval 应用属性,但位置 0 的值已经被提前合入,造成
|
||
后续 interval 污染。
|
||
|
||
最小语义场景:
|
||
|
||
```text
|
||
FINAL-TEXT:
|
||
[0,2) face=bold
|
||
[2,4) face=italic
|
||
```
|
||
|
||
本次批处理复现中:
|
||
|
||
- 字符串初次应用的后半段同时得到 `bold` 与 `italic`;
|
||
- 缓冲区初次应用把 `bold` 扩散到整个替换范围。
|
||
|
||
而后续响应式更新已有按 interval 保留属性的专门路径
|
||
([`tp-render.el`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-render.el#L339-L427))。这说明当前同一功能的“初次应用”
|
||
和“更新应用”使用了两套不完全一致的合并引擎。
|
||
|
||
现有 [`tp-render-tests.el`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-render-tests.el#L318-L334) 覆盖了后续响应式更新,
|
||
没有覆盖初次应用的这个状态。
|
||
|
||
**根因:**
|
||
|
||
不是简单漏了一个条件,而是“字符串内嵌属性的所有者”在两个阶段不一致:
|
||
|
||
- 一条路径把位置 0 属性提升为全区域属性;
|
||
- 另一条路径把内嵌属性视为 per-interval 数据。
|
||
|
||
**修复方向:**
|
||
|
||
只保留一个 per-interval 合并入口。禁止任何初次应用路径把 position 0 的属性
|
||
当成整串代表值。修复前先加入字符串与缓冲区两组红色回归测试。
|
||
|
||
### 6.3 TP-A02:层重定义存在两条独立的刷新缺陷
|
||
|
||
**状态:两条均已确认。**
|
||
|
||
#### A. 静态层重定义不触发已应用区域刷新
|
||
|
||
静态 simple 层的定义路径只更新 `tp-layer-alist`,没有调用
|
||
`tp--layer-refresh`([`tp-layer.el`](../tp-layer.el#L427-L439))。
|
||
|
||
复现流程:
|
||
|
||
```elisp
|
||
(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"))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
face=bold
|
||
help-echo="old"
|
||
stack=((audit-static face bold help-echo "old"))
|
||
```
|
||
|
||
预期:
|
||
|
||
```text
|
||
face=italic
|
||
help-echo="new"
|
||
stack=((audit-static face italic help-echo "new"))
|
||
```
|
||
|
||
因此 `face` 也完全不刷新,这与“旧键残留”不是同一个问题。
|
||
|
||
#### B. 已触发 refresh 时,只更新新键,不删除旧键
|
||
|
||
响应式层重定义会触发 refresh,但
|
||
[`tp--merge-props-into-stack-entry`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-render.el#L142-L152)
|
||
只复制旧 entry 后写入新 plist 出现的键;
|
||
[`tp--update-layer-regions`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-render.el#L190-L233)
|
||
也只遍历新 props 做 `put-text-property`。
|
||
|
||
复现把响应式层从:
|
||
|
||
```elisp
|
||
(face (:foreground $audit-fg) help-echo "old")
|
||
```
|
||
|
||
重定义为:
|
||
|
||
```elisp
|
||
(face (:background $audit-bg))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
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`](../tp-ops.el#L66-L84) 使用:
|
||
|
||
```elisp
|
||
(let ((existing (plist-get result key)))
|
||
(if existing ... embedded-value))
|
||
```
|
||
|
||
当调用者明确传入 `(custom nil)` 时,`plist-get` 返回 nil,代码把它误判为
|
||
“调用者没有这个键”,最终让内嵌值覆盖显式 nil。
|
||
|
||
本次复现:
|
||
|
||
```text
|
||
内嵌字符串:custom=embedded
|
||
调用属性:custom=nil
|
||
期望:custom 键存在,值为 nil
|
||
实际:custom=embedded
|
||
```
|
||
|
||
**修复方向:**
|
||
|
||
凡是判断“键是否由调用者提供”必须使用 `plist-member`;`plist-get` 只用于取值。
|
||
随后应全库搜索相同模式,而不是只修这一处。
|
||
|
||
### 6.5 TP-A04:VALUE=nil 无法表达精确搜索
|
||
|
||
**状态:已确认。**
|
||
|
||
[`tp-search`](../tp-search.el#L904-L992) 的条件是:
|
||
|
||
```elisp
|
||
(or (null value)
|
||
(equal prop-val value))
|
||
```
|
||
|
||
因此 nil 同时表示:
|
||
|
||
- 调用者省略 VALUE,匹配“属性存在且任意值”;
|
||
- 调用者明确传入 nil,匹配“属性存在且值为 nil”。
|
||
|
||
两者无法区分。
|
||
|
||
更严重的是 [`tp-forward`](../tp-search.el#L521-L566):
|
||
|
||
- 字符串路径委托给 `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`](../tp-ops.el#L1050-L1072)。
|
||
|
||
**修复方向:**
|
||
|
||
先定义子属性删除后父属性为空时的唯一契约:
|
||
|
||
- 若父属性没有剩余有效内容,应移除父键;
|
||
- 若业务确实需要显式 nil,应由调用者明确请求,而不是删除操作偶然产生。
|
||
|
||
字符串和缓冲区必须使用同一个纯函数计算“旧值 → 新值/删除标记”,然后各自
|
||
只负责写回。
|
||
|
||
### 6.7 TP-A06:`NOERROR` 吞掉层内部执行错误
|
||
|
||
**状态:已确认。**
|
||
|
||
[`tp-put-layer`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-stack.el#L279-L342) 的 `NOERROR` 路径使用宽泛的
|
||
`condition-case nil ... (error ...)`。本次定义一个解析成功但求值时主动报错的
|
||
参数化层后,`NOERROR=t` 返回 nil,内部的真实错误也被吞掉。
|
||
|
||
`NOERROR` 合理的语义应当仅是:
|
||
|
||
> 找不到指定层/无法解析用户给出的层名时不报错。
|
||
|
||
它不应当吞掉:
|
||
|
||
- 参数化层 body 的 bug;
|
||
- 响应式计算错误;
|
||
- 非法属性结构;
|
||
- 栈编解码不变量破坏;
|
||
- 任意其他内部异常。
|
||
|
||
**修复方向:**
|
||
|
||
不要围住整个执行路径捕获 `error`。先进行可返回“not found”的窄解析,再让后续
|
||
错误自然传播。
|
||
|
||
### 6.8 TP-A07:命名层有两种不兼容的应用语义
|
||
|
||
**状态:已确认。**
|
||
|
||
直接属性操作中的层名解析:
|
||
|
||
```elisp
|
||
(tp-set object 'my-layer ...)
|
||
```
|
||
|
||
通常展开为该层的属性,但不保存 `tp-name`
|
||
([`tp-layer.el`](../tp-layer.el#L1353-L1366))。因此它是“一次性模板展开”。
|
||
|
||
层栈操作:
|
||
|
||
```elisp
|
||
(tp-push-layer object 'my-layer ...)
|
||
(tp-put-layer object ... 'my-layer ...)
|
||
```
|
||
|
||
会保存 `tp-name` 和 `tp-layers`
|
||
([`tp-layer.el`](../tp-layer.el#L1419-L1477)),因此是“受管理实例”。
|
||
|
||
这两种行为各自都可以合理,但使用同一个“应用层”词汇会让用户自然期待:
|
||
|
||
- 后续可按层名查询或删除;
|
||
- 层重定义会更新已应用区域;
|
||
- 响应式系统能找到该层;
|
||
- 直接设置与 push 只差栈位置。
|
||
|
||
实际并非如此。
|
||
|
||
**修复方向:**
|
||
|
||
正式命名两个概念:
|
||
|
||
1. **展开模板**:把层定义解析成普通 plist,不保留身份,不参与生命周期;
|
||
2. **挂载层实例**:保存身份、进入层栈、可刷新、可按名操作。
|
||
|
||
关键不是增加包装函数,而是让文档、函数名、返回值和测试始终使用同一词汇。
|
||
|
||
### 6.9 TP-A08:修改性、坐标和返回值没有统一模型
|
||
|
||
**状态:已确认。**
|
||
|
||
#### 修改性
|
||
|
||
`tp-set/reset/add`:
|
||
|
||
- 整串字符串形式返回新字符串;
|
||
- 字符串 region 形式原地修改;
|
||
- 缓冲区原地修改。
|
||
|
||
层栈的大多数字符串形式又是原地修改
|
||
([`tp-stack.el`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-stack.el#L494-L524))。
|
||
|
||
这意味着仅从函数名或 OBJECT 类型无法判断是否修改原对象,还必须记住调用形式。
|
||
|
||
#### 返回值
|
||
|
||
当前常见返回值包括:
|
||
|
||
- 新字符串;
|
||
- 原字符串;
|
||
- `(START . END)`;
|
||
- 被修改的 property run 数量;
|
||
- `prop-match`;
|
||
- `(START END VALUE)` 列表;
|
||
- nil;
|
||
- 层栈查询中的 union、最大深度、首个 top。
|
||
|
||
其中“property run 数量”不是稳定业务语义,因为无关属性边界也能改变 run 数量。
|
||
|
||
#### 坐标
|
||
|
||
字符串通常为 0-based、缓冲区为 1-based,这是原生约定,本身合理;但
|
||
[`tp-intervals`](../tp-core.el#L117-L148) 的缓冲区结果默认又改为相对 START 的
|
||
0-based offset,只有 `ABSOLUTE` 才返回原生坐标。一个用户从查询结果继续调用
|
||
`tp-set` 时容易发生 off-by-one。
|
||
|
||
**修复方向:**
|
||
|
||
不必强行让字符串和缓冲区使用同一坐标基数,但必须统一:
|
||
|
||
- 默认返回 OBJECT 的原生坐标;
|
||
- 相对坐标必须通过显式选项请求;
|
||
- 查询返回值使用同一种结构;
|
||
- 原地修改与复制式转换必须能从入口名称或显式参数判断;
|
||
- 修改函数返回稳定的“是否变化/结果对象”,诊断统计另设调试入口。
|
||
|
||
### 6.10 TP-A09:文档和实现存在明确矛盾
|
||
|
||
**状态:已确认。**
|
||
|
||
代表性例子:
|
||
|
||
1. [`tp--resolve-props`](../tp-layer.el#L1160-L1205) docstring 说符号层名会包含
|
||
`tp-name`,实现却明确传入 nil,不包含 `tp-name`
|
||
([`tp-layer.el`](../tp-layer.el#L1359-L1366))。
|
||
2. [`define-tp`](../tp-layer.el#L316-L367) 的当前使用示例仍声称直接 `tp-set`
|
||
会得到 `tp-name`,与 README 和当前实现相反。
|
||
3. [`tp-search-map`](../tp-search.el#L1053-L1101) docstring 说字符串长度不同时
|
||
会截断/部分替换,但实际实现
|
||
[`tp--replace-match-text`](../tp-search.el#L670-L736) 会明确报错。
|
||
4. README 宣称“统一 API 参数规范”,但同一文档同时记录了字符串复制/原地
|
||
修改和搜索结果结构的差异。
|
||
|
||
**修复方向:**
|
||
|
||
先写机器可验证的语义表,再从该表更新 README 与 docstring。避免继续分别修补
|
||
中英文文档和函数说明而没有共同规范。
|
||
|
||
### 6.11 TP-A10:隐藏层状态会静默覆盖外部直接属性编辑
|
||
|
||
**状态:已确认,且内部 docstring 已明确说明。**
|
||
|
||
当任意层处于 hidden 状态时,`tp-layers` 保存完整层栈,字符的其他直接属性只是
|
||
临时渲染表面。栈编解码文档明确说明,期间发生的原生或 tp 直接属性编辑会在
|
||
下一次 stack operation 时被丢弃
|
||
([`tp-layer.el`](../tp-layer.el#L1519-L1565))。
|
||
|
||
本次最小复现:
|
||
|
||
1. push 一个 `face=bold` 的 managed layer;
|
||
2. hide 该层;
|
||
3. 直接写入 `help-echo="external"`;
|
||
4. show 该层。
|
||
|
||
show 前:
|
||
|
||
```text
|
||
(help-echo "external"
|
||
tp-layers ((tp-hidden t face bold tp-name audit-hidden)))
|
||
```
|
||
|
||
show 后:
|
||
|
||
```text
|
||
(face bold tp-name audit-hidden)
|
||
```
|
||
|
||
`help-echo` 无提示地消失。这不是单纯的内部实现细节,而是用户数据所有权冲突,
|
||
应当与 correctness 缺陷一起处理,不能推迟到新增高级层功能之后。
|
||
|
||
**修复方向:**
|
||
|
||
阶段 0 就必须选定默认规则。最小且安全的默认是:managed hidden range 检测到
|
||
无法归属的直接属性变化时,在下一次栈写入前明确报出冲突;若决定采用
|
||
“收编为匿名层”或“明确覆盖”,也必须由公开契约和回归测试证明,不能静默决定。
|
||
|
||
### 6.12 TP-A11:业务计算与 observer 的错误边界混在一起
|
||
|
||
**状态:源码确认,具体产品策略待定。**
|
||
|
||
当前:
|
||
|
||
- transform 出错后 message 并返回原文本
|
||
([`tp-ops.el`](../tp-ops.el#L50-L64));
|
||
- initial compute 出错后 message 并跳过赋值
|
||
([`tp-reactive.el`](../tp-reactive.el#L352-L368));
|
||
- watcher 出错后 message 并继续
|
||
([`tp-reactive.el`](../tp-reactive.el#L306-L318))。
|
||
|
||
三者不应使用同一策略:
|
||
|
||
- transform/compute 决定业务输出,失败后 fallback 或跳过会留下貌似有效、实际
|
||
错误或过期的文本,应默认传播到外层错误边界;
|
||
- watcher 如果只是 observer,隔离单个 callback 可以合理,但必须留下可查询的
|
||
结构化失败,而不只是易被忽略的 minibuffer message。
|
||
|
||
**修复方向:**
|
||
|
||
先按“计算结果所有者”和“副作用观察者”分类,再收敛错误边界。不要为每个函数
|
||
新增一个静默选项;默认让业务计算失败,顶层批量渲染可以汇总多个错误。
|
||
|
||
### 6.13 TP-A12:响应式注册表依赖入口完整性
|
||
|
||
**状态:源码明确承认。**
|
||
|
||
[`tp-reactive-layer-buffers`](../tp-reactive.el#L86-L103) 已记录:
|
||
|
||
> 插入一个已经带 `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`](../tp-ops.el#L248-L317) 支持多种调用形式,并根据:
|
||
|
||
- 第一个参数是 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` 混合回答了不同问题:
|
||
|
||
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`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-stack.el#L117-L126) 返回区域中出现过的层名 union;
|
||
[`tp-layer-count`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-stack.el#L128-L136) 返回所有 run 的最大深度;
|
||
[`tp-layer-top`](https://github.com/Kinneyzhang/tp/blob/a65d799/tp-stack.el#L143-L157) 返回第一个带名字的 top。
|
||
|
||
这些定义不是错误,但函数名看起来像在描述“整个区域的统一状态”。当区域内部
|
||
异质时,调用者无法从标量结果判断:
|
||
|
||
- 每个 run 是否相同;
|
||
- 某层覆盖全部区域还是只出现一次;
|
||
- top 是否统一;
|
||
- count 的分布是什么。
|
||
|
||
建议保留便利标量,同时提供或强化 run-aware 查询作为权威入口。标量 docstring
|
||
中应直接出现 union/max/first,而不是只写“in region”。
|
||
|
||
### 7.6 错误策略应只有两个边界
|
||
|
||
合理的错误边界:
|
||
|
||
1. 参数解析/公共命令边界:把非法用户输入转成清晰错误;
|
||
2. 可选的用户 callback 边界:说明 callback 失败是否终止渲染。
|
||
|
||
当前策略混合了:
|
||
|
||
- transform 出错后 message 并退回原文本
|
||
([`tp-ops.el`](../tp-ops.el#L50-L64));
|
||
- watcher 出错后 message 并继续
|
||
([`tp-reactive.el`](../tp-reactive.el#L306-L318));
|
||
- initial compute 出错后跳过
|
||
([`tp-reactive.el`](../tp-reactive.el#L352-L368));
|
||
- `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`](../tp-layer.el#L1519-L1565))。
|
||
|
||
这是重要的用户数据所有权问题,不能只放在内部 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-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 第一层:为已确认问题补红色回归
|
||
|
||
必须先加入:
|
||
|
||
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 中承诺等价的操作,使用同一随机输入分别执行:
|
||
|
||
```text
|
||
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
|
||
|
||
随机生成操作序列:
|
||
|
||
```text
|
||
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-A01~A06 和 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
|
||
|
||
状态:已完成(内部模型)。公开入口和历史返回值保持兼容。
|
||
|
||
不要先追求更多便利重载。先保证内部存在唯一规范路径:
|
||
|
||
```text
|
||
request
|
||
= object
|
||
+ native range
|
||
+ operation
|
||
+ properties/value/predicate
|
||
+ mutation policy
|
||
+ error/read-only policy
|
||
```
|
||
|
||
现有 `tp-set/reset/add/...` 可以继续作为外观入口,但都解析到同一规范路径。
|
||
|
||
停止条件:
|
||
|
||
- [x] 字符串与缓冲区的等价场景只在 I/O 边界分支;
|
||
- [x] 查询内部结果结构不再随对象类型改变;公开返回保持历史兼容;
|
||
- [x] 内部 canonical range 使用原生坐标;`tp-intervals` / `tp-intervals-map` 的缓冲区 relative 默认是保留的公开兼容例外;
|
||
- [x] “是否原地修改”可从公开调用清楚判断。
|
||
|
||
### 阶段 3:补齐原生文本属性语义
|
||
|
||
状态:已完成(text-only)。overlay-aware 字符属性查询见阶段 5。
|
||
|
||
按价值从高到低:
|
||
|
||
1. [x] presence-aware exact nil;
|
||
2. [x] next/previous single/all property change;
|
||
3. [x] `text-property-any` / `not-all` / predicate search 对齐;
|
||
4. [x] direct vs effective lookup;
|
||
5. [x] category/default/alias 有效值委托成为明确且经过测试的 lookup 契约;
|
||
6. [x] read-only、modified、undo 和 silent modification 的三种有效策略;
|
||
7. [x] stickiness 与 insert 行为直接委托 Emacs,无 tp wrapper;
|
||
8. [x] copy/insert/kill/yank 属性保留与过滤直接委托 Emacs,无 tp wrapper;
|
||
9. [x] narrowing 与 indirect buffer 坐标/共享文本行为通过原生等价性测试。
|
||
|
||
停止条件:与选定 GNU Emacs 基线的原生等价性测试通过。
|
||
|
||
### 阶段 4:完善 managed layer 生命周期
|
||
|
||
状态:已完成。
|
||
|
||
建议扩展:
|
||
|
||
- [x] 显式 managed layer attach/detach;
|
||
- [x] 层实例参数和版本;
|
||
- [x] old/new entry 全量替换与参数化 args refresh;
|
||
- [x] 已选外部编辑冲突策略在 push/hide/show/merge 全路径保持一致;
|
||
- [x] 单 managed layer 使用 `tp-layers` 保存 authoritative `tp-meta`,direct rendered/public stack query 不暴露 `tp-meta`;
|
||
- [x] 插入带层字符串后的显式 attach;
|
||
- [x] theme generation 与 enable/disable conservative refresh diagnostics;
|
||
- [x] registry 诊断与泄漏检查入口;
|
||
- [x] layer transaction 的结构化成功/失败和 rollback 报告;
|
||
- [ ] compositional layer 作为可选扩展,未纳入当前完成条件。
|
||
|
||
停止条件:随机操作序列下 stack/render/registry 不变量持续成立。
|
||
|
||
### 阶段 5:可选的字符属性兼容层
|
||
|
||
状态:已完成(lookup)。overlay lifecycle 明确排除。
|
||
|
||
若产品确实要使用“所有字符属性操作”定位,再增加:
|
||
|
||
- [x] text-only 与 overlay-aware lookup 模式;
|
||
- [x] source-aware result,overlay 获胜时报告 `:overlay` 与 overlay identity;
|
||
- [x] overlay priority/change boundary 委托 Emacs `get-char-property-and-overlay`;
|
||
- [x] 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 之外,可增加按属性键独立合成的模式:
|
||
|
||
```text
|
||
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`](../tp-palette.el#L256-L302)),但静态已应用结果不会自然随
|
||
主题变化。
|
||
可以引入:
|
||
|
||
- 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. “可以宣称完整替代”的验收清单
|
||
|
||
只有同时满足以下条件,才建议宣称“完整文本属性操作替代层”:
|
||
|
||
### 核心契约
|
||
|
||
- [x] 字符串/缓冲区的修改性在公开入口上可预测;
|
||
- [x] 默认坐标是对象原生坐标(`tp-intervals` / `tp-intervals-map` 的 relative 默认为公开兼容例外);
|
||
- [x] 所有 text-only 查询区分缺失与显式 nil;
|
||
- [x] wildcard 与 VALUE=nil 不复用同一个参数状态;
|
||
- [x] 内部查询结果结构不随对象类型改变;部分 public returns 保持历史兼容;
|
||
- [x] Stage 1 错误传播与 NOERROR 范围有规范;
|
||
- [x] read-only/modified/undo 的三种 mutation policy 组合有测试保证。
|
||
|
||
### 原生覆盖
|
||
|
||
- [x] put/add/set/remove/remove-list/clear 有明确映射;
|
||
- [x] text-properties-at/get-text-property 有明确映射;
|
||
- [x] property change/search 家族有明确映射;
|
||
- [x] category/default/alias 有 direct/effective 区分;
|
||
- [x] get-char-property / get-char-property-and-overlay 查询有明确映射;
|
||
- [x] stickiness 与插入继承有文档和测试;
|
||
- [x] copy/substring/insert/kill/yank 有文档和测试;
|
||
- [x] special properties 明确委托给 Emacs,不被 tp 破坏;
|
||
- [x] narrowing/indirect buffer 行为通过测试。
|
||
|
||
### managed layers
|
||
|
||
- [x] 直接模板展开和 managed layer 实例名称不同、行为不同;
|
||
- [x] 层重定义能删除旧贡献;
|
||
- [x] hidden/buried layer 始终保存最新 entry;
|
||
- [x] 外部直接属性修改不会被静默丢失;
|
||
- [x] 插入 propertized string 后的 attach/track 行为明确;
|
||
- [x] 参数化 mounted layer args/version refresh 通过测试;
|
||
- [x] transaction success/failure rollback 与 diagnostics 通过测试;
|
||
- [x] theme generation diagnostics 通过测试。
|
||
|
||
### 工程证据
|
||
|
||
- [x] 原生等价性测试覆盖选定 Emacs 版本;
|
||
- [x] 性能基准覆盖大文本、碎片 interval、深层栈和 fan-out(结果为 advisory baseline,不是发布阈值);
|
||
- [x] 中英文 README、docstring、doctest 来自同一语义规范;
|
||
- [ ] CI 全绿且 warning 为零。
|
||
|
||
---
|
||
|
||
## 15. 最终优先级
|
||
|
||
### 立即处理
|
||
|
||
1. TP-A01:`tp-text` 初次多 interval 属性扩散;
|
||
2. TP-A10:阻止 managed hidden range 静默丢弃外部直接属性;
|
||
3. TP-A02:静态层重定义与层刷新 old/new 所有权;
|
||
4. TP-A03/A04/A05:presence、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 上完成以下验证:
|
||
|
||
```text
|
||
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](https://www.gnu.org/software/emacs/manual/html_node/elisp/Text-Properties.html)
|
||
- [Examining Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Examining-Properties.html)
|
||
- [Changing Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Changing-Properties.html)
|
||
- [Text Property Search Functions](https://www.gnu.org/software/emacs/manual/html_node/elisp/Property-Search.html)
|
||
- [Why Text Properties are not Intervals](https://www.gnu.org/software/emacs/manual/html_node/elisp/Not-Intervals.html)
|
||
- [Stickiness of Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Sticky-Properties.html)
|
||
- [Properties with Special Meanings](https://www.gnu.org/software/emacs/manual/html_node/elisp/Special-Properties.html)
|
||
- [Fields](https://www.gnu.org/software/emacs/manual/html_node/elisp/Fields.html)
|
||
- [Yanking](https://www.gnu.org/software/emacs/manual/html_node/elisp/Yanking.html)
|
||
- [Insertion](https://www.gnu.org/software/emacs/manual/html_node/elisp/Insertion.html)
|
||
- [Narrowing](https://www.gnu.org/software/emacs/manual/html_node/elisp/Narrowing.html)
|
||
- [Indirect Buffers](https://www.gnu.org/software/emacs/manual/html_node/elisp/Indirect-Buffers.html)
|
||
- [Overlay Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Overlay-Properties.html)
|
||
|
||
## 18. 最小动态复现
|
||
|
||
以下表达式均在仓库根目录、Emacs 30.2、dash 2.20.0、快照 `a65d799` 上复核。
|
||
通用命令为:
|
||
|
||
```sh
|
||
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 污染
|
||
|
||
```elisp
|
||
(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))))))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
string: p0=bold p2=(bold italic)
|
||
buffer: p1=bold p3=bold
|
||
```
|
||
|
||
预期后半段只为 `italic`。
|
||
|
||
### 18.2 TP-A02-A:静态层重定义不刷新
|
||
|
||
```elisp
|
||
(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)))))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
face=bold help="old"
|
||
stack=((audit-static face bold help-echo "old"))
|
||
```
|
||
|
||
### 18.3 TP-A02-B:已触发 refresh 仍残留旧键
|
||
|
||
```elisp
|
||
(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)))))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
face=(:background "blue") help="old"
|
||
stack=((audit-reactive face (:background "blue") help-echo "old"))
|
||
```
|
||
|
||
预期 stack 和直接属性中都不再存在 `help-echo`。
|
||
|
||
### 18.4 TP-A03:显式 nil 被内嵌值覆盖
|
||
|
||
```elisp
|
||
(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))))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
member=(custom embedded)
|
||
props=(custom embedded tp-text #("X" 0 1 (custom embedded)))
|
||
```
|
||
|
||
预期 `member=(custom nil)`。
|
||
|
||
### 18.5 TP-A04:nil 搜索在字符串与缓冲区中不同
|
||
|
||
```elisp
|
||
(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))))))))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
string=((0 1 x))
|
||
buffer=(2 4 nil)
|
||
```
|
||
|
||
字符串路径把 nil 当作 wildcard,缓冲区路径按 nil 值搜索,并把“缺失”和
|
||
“显式 nil”的相邻范围合并为一个有效值为 nil 的 match。
|
||
|
||
### 18.6 TP-A05:子属性删除后的 presence 不同
|
||
|
||
```elisp
|
||
(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)))))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
string=(face nil) props=(face nil)
|
||
buffer=nil props=nil
|
||
```
|
||
|
||
### 18.7 TP-A06:`NOERROR` 吞掉层 body 错误
|
||
|
||
```elisp
|
||
(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)))))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
result=nil props=nil
|
||
```
|
||
|
||
关闭 `NOERROR` 时同一层 body 会正常抛出 `audit boom 1`。
|
||
|
||
### 18.8 TP-A10:外部直接属性被后续栈操作丢弃
|
||
|
||
```elisp
|
||
(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)))))
|
||
```
|
||
|
||
实际:
|
||
|
||
```text
|
||
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` 后无提示消失。
|