feat(tp): add retained surfaces and atomic publication

Introduce pure retained plans, stable objects, marker-backed range ownership, side indexes, content/property diffs, and multi-buffer rollback as the generic publication runtime.

Verification: 707 ERT tests; 92 doctests; shuffled seed 20260806; compile-all WERROR=t.
This commit is contained in:
Kinneyzhang 2026-08-06 03:23:59 +08:00
parent 183f49ed3e
commit 84ebf0a548
10 changed files with 2636 additions and 23 deletions

View File

@ -6,6 +6,7 @@ All notable changes to the tp library are documented here.
### Added
- TP 1.0 retained surfaces now provide defensive pure plans, prepare-scoped object identity, keyed/positional reconciliation, `content` and `properties` capabilities, marker-backed range anchors, same-surface overlapping property contributions, compare-before-write conflicts and explicit rebase, common-prefix/suffix text edits, property-run diffs, side indexes, opaque client state, generic reports, lifecycle cleanup, and atomic multi-buffer publication with exact rollback. Pure materialization uses the same plan semantics without leaving live handles or subscriptions.
- TP 1.0 signals and bindings now form an exact source→binding and binding→binding dependency graph with conditional rewiring, memoized equality cutoffs, transaction-local candidate signal values, deduplicated topological flushing, nested-write stabilization, rollback, cycle paths, owner disposal, buffer-scoped sources, variable adapters, and public scheduler counters. The legacy layer scanner remains isolated only until the retained-surface cutover.
- The first TP 1.0 runtime slice: `tp-style.el` provides atomic namespaced property schemas, structured subject selectors and combinators, deterministic origin/importance/layer/specificity/scope/source-order cascade, property-specific inheritance, tagged CSS-wide values, custom-property fallback/cycle handling, explicit `tp-computed` value sources, named declarations, provenance, and final Emacs-property projection. Ordinary function values remain literal.
- Internal Stage 2 canonical façade records and dataflow:

View File

@ -19,7 +19,7 @@ WERROR ?= nil
TEST_DIR = tests
LOADPATH = -L . -L $(TEST_DIR) $(LOAD_EXTRA)
SRC = tp-core.el tp-style.el tp-reactive.el tp-layer.el tp-ops.el tp-search.el \
SRC = tp-core.el tp-style.el tp-reactive.el tp-surface.el tp-layer.el tp-ops.el tp-search.el \
tp-render.el tp-stack.el tp-query.el tp-palette.el tp-builtins.el tp.el
TESTS = $(wildcard $(TEST_DIR)/*-tests.el)
TEST_SUPPORT = $(TEST_DIR)/tp-doctest.el $(TEST_DIR)/tp-run-shuffled.el

View File

@ -178,6 +178,8 @@ The TP 1.0 migration has begun with a pure schema-driven cascade kernel. `tp-sty
`tp-reactive.el` now also provides the TP 1.0 exact dependency runtime: signals invalidate only their subscribed bindings, binding reads form memoized binding→binding edges, conditional computations replace obsolete dependencies, and the outermost `tp-with-transaction` flushes each dirty binding once. Candidate signal writes and binding values/dependencies commit together; compute or cycle failures roll them back. Global and buffer-local variable adapters reuse this graph without making text properties or buffer scans the runtime database.
`tp-surface.el` completes the retained publication path from object identity to marker-backed mounts and buffers. A producer receives a short-lived prepare context, ensures candidate objects before computing output, and returns a defensive pure plan plus optional opaque client state. TP validates the whole candidate before publication, applies common-prefix/suffix text edits and property-run diffs, swaps plans/indexes/client state at one revision, and rolls every affected buffer and signal back together on failure. `content` owns a disjoint text span; `properties` decorates attached host ranges, detects external property conflicts, and requires explicit rebase. Overlap is composable inside one surface and rejected across independent surfaces so ownership cannot silently split.
### Core Innovations
1. **Unified API Parameter Conventions**: All functions support multiple flexible calling patterns, working seamlessly with both strings and buffers
@ -435,6 +437,7 @@ The modules and their roles:
| `tp-core.el` | Intervals, plist/face merge engine, debug logging, `$var` utilities |
| `tp-style.el` | Namespaced property schemas, structured selectors, cascade, custom properties, and explicit computed values |
| `tp-reactive.el` | Exact signals, memoized bindings, transactions, scoped variable adapters, and temporary legacy watcher state |
| `tp-surface.el` | Retained plans, prepare/object lifecycle, range anchors, mounts, side indexes, diffs, reports, and atomic publication |
| `tp-layer.el` | `define-tp` / `define-tps`, layer registry and resolution |
| `tp-ops.el` | `tp-set` / `tp-reset` / `tp-add` / `tp-get` / `tp-at` / `tp-remove` / `tp-clear` |
| `tp-search.el` | `tp-match-*`, `tp-regexp-*`, `tp-search`, navigation |
@ -530,6 +533,17 @@ A complete overview of all tp.el functions organized by category:
| `tp-variable-signal` | Adapt a global or buffer-local Elisp variable into a scoped signal |
| `tp-reactive-counters` / `tp-reactive-reset-counters` | Read or reset public scheduler work counters |
#### Retained Surfaces
| Function | Description |
|----------|-------------|
| `tp-surface-plan-create` / `tp-surface-result-create` | Build defensive pure plan data and attach optional opaque client state |
| `tp-object-ensure` / `tp-object-resolve` | Allocate candidate identity during prepare or resolve committed identity by key path |
| `tp-range-anchor-create` / `tp-object-attach-range` / `tp-range-rebase` | Attach host-owned text ranges without putting positions in plans |
| `tp-surface-materialize-string` | Render a plan or ephemeral producer without live identity, markers, or subscriptions |
| `tp-surface-mount` / `tp-surface-update` / `tp-surface-unmount` | Own the only live publication lifecycle |
| `tp-surface-at-point` / `tp-surface-inspect` / `tp-surface-report` | Query retained side indexes and generic commit diagnostics without scanning text |
#### Property Layer Definition Functions
| Function | Description |
|----------|-------------|

View File

@ -177,6 +177,8 @@ TP 1.0 迁移已经从纯 schema-driven cascade kernel 开始。`tp-style.el`
`tp-reactive.el` 现在也提供 TP 1.0 的精确依赖 runtimesignal 只 invalidates 真实订阅的 bindingbinding read 建立 memoized binding→binding edge条件计算会替换已经失效的旧依赖最外层 `tp-with-transaction` 对每个 dirty binding 最多 flush 一次。candidate signal write 与 binding value/dependencies 一起提交compute 或 cycle 失败时一起回滚。global 和 buffer-local variable adapter 复用同一 graph不再把文本属性或 buffer scan 当作新 runtime database。
`tp-surface.el` 补全了从 object identity 到 marker-backed mount 和 buffer 的 retained publication 路径。producer 获得短生命周期 prepare context在计算输出前确保 candidate object并返回防御性 pure plan 与可选 opaque client state。TP 在 publication 前验证整个 candidate执行 common-prefix/suffix text edit 与 property-run diff并以同一个 revision 切换 plan/index/client state失败时把所有受影响 buffer 与 signal 一起回滚。`content` 拥有一个不重叠的文本 span`properties` 装饰 attached host range检测外部 property conflict并要求显式 rebase。同一 surface 内的 overlap 可以合成;独立 surface 之间的 overlap 会被拒绝,避免所有权静默分裂。
### 核心创新
1. **统一的 API 参数规范**:所有函数支持多种灵活的调用方式,同时适用于字符串和缓冲区
@ -431,6 +433,7 @@ OBJECT 总是位于最后nil 表示当前缓冲区)。所有核心函数和
| `tp-core.el` | 区间、plist/face 合并引擎、调试日志、`$var` 工具 |
| `tp-style.el` | namespaced property schema、结构化 selector、cascade、custom property 和显式 computed value |
| `tp-reactive.el` | 精确 signal、memoized binding、transaction、scoped variable adapter 和临时 legacy watcher state |
| `tp-surface.el` | retained plan、prepare/object lifecycle、range anchor、mount、side index、diff、report 和原子 publication |
| `tp-layer.el` | `define-tp` / `define-tps`、属性层注册表与解析 |
| `tp-ops.el` | `tp-set` / `tp-reset` / `tp-add` / `tp-get` / `tp-at` / `tp-remove` / `tp-clear` |
| `tp-search.el` | `tp-match-*`、`tp-regexp-*`、`tp-search`、导航 |
@ -525,6 +528,17 @@ tp.el 所有函数按类别组织的完整概览:
| `tp-variable-signal` | 把 global 或 buffer-local Elisp variable 适配为 scoped signal |
| `tp-reactive-counters` / `tp-reactive-reset-counters` | 读取或重置 public scheduler work counters |
#### Retained Surfaces
| 函数 | 描述 |
|------|------|
| `tp-surface-plan-create` / `tp-surface-result-create` | 构造防御性 pure plan data并附带可选 opaque client state |
| `tp-object-ensure` / `tp-object-resolve` | 在 prepare 中分配 candidate identity或按 key path 解析 committed identity |
| `tp-range-anchor-create` / `tp-object-attach-range` / `tp-range-rebase` | 挂载 host-owned text range不把 position 放进 plan |
| `tp-surface-materialize-string` | 物化 plan 或 ephemeral producer不留下 live identity、marker 或 subscription |
| `tp-surface-mount` / `tp-surface-update` / `tp-surface-unmount` | 唯一 live publication lifecycle |
| `tp-surface-at-point` / `tp-surface-inspect` / `tp-surface-report` | 不扫描文本地查询 retained side index 与 generic commit diagnostics |
#### 属性层定义函数
| 函数 | 描述 |
|------|------|

View File

@ -192,3 +192,13 @@ tp 不会把外部属性收编成新的匿名层,也不会静默覆盖它。
- 任一 compute 或 cycle 失败会恢复 committed signal values、last successful binding values、dependencies、dirty state、owner registry 和 scheduler counters。cycle condition 携带 namespaced binding-key path。
- `tp-variable-signal` 是 global/buffer-local Elisp variable包括后续 `$var` compiler的 source adapter它只转发精确 scope 的 write不调用 legacy layer renderer。
- `tp-reactive-counters` 只公开 invalidated、recomputed、skipped、subscription-added、subscription-removed 五个工作量计数,不暴露 internal hash shape。
## 14. Retained surface、range ownership 与 publication
- `tp-surface-plan-create` 只接受 key/kind/text/props/children/tags/capability 纯数据text 与 children 互斥,同一 parent 的显式 key 不可重复constructor 与每次 prepare 都做 defensive copy。普通 function property value 保持 literal identityplan 不携带 marker、position、binding 或 producer closure。
- producer 在 active prepare context 中先用 `tp-object-ensure` 取得 candidate handle再返回 plan 或 `tp-surface-result-create`。成功 publication 才使新 handle live失败、materialize 返回或 orphan/cross-surface validation 失败都会释放 candidate bindings、anchors 与 subscriptions。`tp-object-resolve` 只读 live key path不创建 identity。
- `content` capability 拥有一个 disjoint text span使用 character common-prefix/suffix 与 direct-property run diff外部字符编辑使其 stale。`properties` capability 不能携带 text只能通过 `tp-object-attach-range` 写 attached anchor。host edit 位于 anchor 前方时 marker 正常移动;跨入 owned range 时按 boundary policy shorten/remove/stale。
- properties ledger 保存 baseline、last-published value 与 contribution anchors。prepare compare-before-write外部 property override 触发 `tp-property-conflict` 且保持旧 revision。`tp-range-rebase` 显式接受当前 host runs 为新 baselineunmount 只恢复仍等于 TP last-published value 的子区间,冲突子区间保持外部值并进入 report。
- 同一 surface 的 overlapping properties contributions 按 plan order 和 native property schema merge独立 surfaces 当前不得重叠字符 ownership。这个限制把 journal owner 保持为唯一 surface避免两套 baseline 静默覆盖。
- 最外层 transaction 先完成所有 producer/plan/conflict validation再按 surface id publish。multi-buffer change group 负责 text rollbackTP 的精确 property journal 覆盖 `with-silent-modifications`;失败恢复 buffer direct properties、markers/index、objects、plan、client state、revision、bindings 与 signal values。publish 中 killed buffer 不复活,其他 surfaces 与 sources 回滚。
- `tp-surface-report` 的字段只使用 transaction/surface/source/binding/object/text/property/conflict/observer/timing 通用词汇。observer 在成功 commit 且 publishing transaction 的动态范围退出后执行,因此 observer 中的 signal write 会开启新 transactionobserver error 只写入 report不回滚。

View File

@ -2,7 +2,7 @@
> 未来主版本目标TP 将重构为独立 retained/reactive text runtime并可作为 Ebox 等高级 consumer 的通用底层执行器。TP 自身完整、可独立阅读的已批准目标见 [TP Retained/Reactive Text Runtime 目标架构](retained-runtime-target-architecture.md)[English](retained-runtime-target-architecture-en.md));它不依赖 sibling Ebox checkout。本文描述当前已经落地的实现并明确标出仍处于迁移期的旧 runtime 与 TP 1.0 功能切片。
本文档描述 tp 库的模块分层结构与函数调用层次从底层基础模块到上层功能模块的分层组织。TP 1.0 的纯 style/cascade kernel 与 exact signal/binding graph 已分别在 `tp-style.el`、`tp-reactive.el` 落地retained surface 尚未切换。
本文档描述 tp 库的模块分层结构与函数调用层次从底层基础模块到上层功能模块的分层组织。TP 1.0 的纯 style/cascade kernel、exact signal/binding graph 与 retained surface publication 已分别在 `tp-style.el`、`tp-reactive.el`、`tp-surface.el` 落地;旧 managed façade 尚未切换。
当前 API 的规范契约见 [API-SEMANTICS.md](API-SEMANTICS.md)Emacs 原生
文本属性覆盖范围、已确认问题与演进路线见
@ -181,6 +181,12 @@ Stage 2 canonical façade 是内部模型,不改变公开入口和历史返回
文件下半部仍暂存 0.3 façade 所需的变量→layer watcher、batch queue 和 layer→buffer registry以保持旧测试在 retained surface 建成前可运行;它们没有被新 graph 调用,并将在最终 cutover phase 与 `tp-render.el` 扫描路径一起删除。这个暂存区不是第二套长期 public runtime。
### tp-surface.elretained publication owner
只依赖 `tp-core`、`tp-style` 与 `tp-reactive`。它集中拥有 prepare context、candidate/live object identity、pure surface plan、content/properties capability、range anchor、object→mount 与 position→object side index、properties contribution ledger、text/property diff、multi-buffer change group、silent-property inverse journal、opaque client state、revision、generic report 和 buffer-kill lifecycle。normal update 从 binding owner 直接取得 prepared surface再从 object 直接取得 mounts不读取 `buffer-list`,也不按 `tp-name`、`tp-layers` 或显示文本反查 identity。
`content` mount 可以替换其拥有的 disjoint span外部字符编辑会使 mount stale。`properties` mount 只能修改 attached anchor 上声明的 direct properties同一 surface 内重叠 contribution 通过 native property schema 合成。外部值与 TP last-published value 不同时prepare 报 `tp-property-conflict`,调用者必须 `tp-range-rebase` 或 unmount。独立 surfaces 的字符范围当前必须不重叠,以保持单一、可证明的 ownership journal。
#### 依赖注册与管理
| 函数/变量 | 描述 |
|------|------|
@ -574,6 +580,7 @@ insert/copy/yank/stickiness/narrowing/indirect buffer 均不在 tp-query 中封
| tp-core | —— | **无可变状态**(仅 `tp-debug-mode`/`tp-debug-echo` 两个用户选项;调试日志写入 *tp-debug* 缓冲区,由 `tp-debug-clear` 清除) | - |
| tp-style | schema、named style、stylesheet rule、cascade layer/source order registries | 纯 style definition 和 rule 状态;不保存 object、buffer 或 mount | `tp-style-reset` / `tp-style-reset-rules` |
| tp-reactive | signals、owner bindings、dependency subscriber sets、variable adapters、transaction-local scheduler state、public counters | TP 1.0 exact reactive graphnormal update 不扫描 buffer | `tp-reactive-reset`buffer-scoped signal 随 buffer kill |
| tp-surface | buffer-local surfaces、object/mount/index、range anchors、property ledgers、plan/client-state/revision/report | TP 1.0 retained publicationglobal registry 仅 weak-reference | `tp-surface-unmount`buffer kill authoritative teardown |
| tp-reactive | `tp-reactive-deps` | 变量 → 依赖层 注册表 | `tp-reactive-reset` |
| tp-reactive | `tp-layer-watchers` / `tp-layer-computed` / `tp-layer-data` / `tp-reactive-observer-errors` | `:watch` / `:compute` / `:data` 注册表与结构化 observer 错误 | `tp-reactive-reset` |
| tp-reactive | `tp--batch-update-pending` | 批量更新队列0.3.0 起也被 reset 清空,防止残留条目对新定义的层重放) | `tp-reactive-reset` |

554
tests/tp-surface-tests.el Normal file
View File

@ -0,0 +1,554 @@
;;; tp-surface-tests.el --- Tests for TP retained surfaces -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Geekinney
;;; Commentary:
;; Contract tests for TP 1.0 plans, objects, mounts, and publication.
;;; Code:
(require 'ert)
(require 'tp-surface)
(defmacro tp-surface-test--with-buffer (&rest body)
"Run BODY in a temporary live buffer."
(declare (indent 0) (debug t))
`(let ((buffer (generate-new-buffer " *tp-surface-test*")))
(unwind-protect
(with-current-buffer buffer ,@body)
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(defun tp-surface-test--leaf (key text &optional props)
"Return a content leaf with KEY, TEXT, and PROPS."
(tp-surface-plan-create
:key key :kind 'text :text text :props props :capability 'content))
(defun tp-surface-test--producer (signal)
"Return a retained content producer reading SIGNAL."
(lambda (context)
(tp-object-ensure context nil 'root 'text)
(tp-surface-result-create
(tp-surface-test--leaf 'root (number-to-string (tp-signal-read signal)))
(list :value (tp-signal-peek signal)))))
(ert-deftest tp-surface-test-plan-validates-and-defensively-copies ()
"Plans reject duplicate keys and own their caller-provided values."
(let* ((callback (byte-compile
(lambda (_window _object _position) "help")))
(props (list 'help-echo callback))
(text (copy-sequence "A"))
(child (tp-surface-test--leaf 'child text props))
(plan (tp-surface-plan-create
:key 'root :kind 'group :children (list child)
:capability 'content)))
(setcar props 'face)
(aset text 0 ?Z)
(let ((rendered (tp-surface-materialize-string plan)))
(should (equal (substring-no-properties rendered) "A"))
(should (eq (get-text-property 0 'help-echo rendered) callback)))
(should-error
(tp-surface-plan-create
:key 'root :kind 'group
:children (list (tp-surface-test--leaf 'same "A")
(tp-surface-test--leaf 'same "B"))
:capability 'content)
:type 'tp-duplicate-object-key)))
(ert-deftest tp-surface-test-materialize-producer-is-ephemeral ()
"Pure materialization leaves no live object, binding, or subscription."
(let ((signal (tp-signal-create 7)) object binding)
(let ((rendered
(tp-surface-materialize-string
(lambda (context)
(setq object (tp-object-ensure context nil 'root 'text)
binding (tp-bind object '(test . value)
(lambda () (tp-signal-read signal))))
(tp-surface-test--leaf
'root (number-to-string (tp-binding-read binding)))))))
(should (equal rendered "7")))
(should-not (tp-object-live-p object))
(should-not (tp-binding-live-p binding))
(should (= (tp-signal-subscriber-count signal) 0))))
(ert-deftest tp-surface-test-content-mount-retains-keyed-identity ()
"A content update reuses keyed objects and publishes a minimal result."
(tp-surface-test--with-buffer
(let* ((first (tp-surface-test--leaf 'root "old" '(face bold)))
(surface (tp-surface-mount buffer first '(:capability content)))
(object (tp-object-resolve surface '(root))))
(should (equal (buffer-string) "old"))
(cl-letf (((symbol-function 'buffer-list)
(lambda (&rest _) (error "Unexpected buffer scan")))
((symbol-function 'text-property-search-forward)
(lambda (&rest _) (error "Unexpected property scan"))))
(tp-surface-update
surface (tp-surface-test--leaf 'root "new" '(face italic))))
(should (equal (buffer-string) "new"))
(should (eq object (tp-object-resolve surface '(root))))
(should (eq (get-text-property 1 'face buffer) 'italic))
(should (= (plist-get (tp-surface-report surface) :text-operations) 1)))))
(ert-deftest tp-surface-test-failed-candidate-does-not-leak-object ()
"A failed update preserves the live tree and invalidates new handles."
(tp-surface-test--with-buffer
(let* ((surface
(tp-surface-mount buffer (tp-surface-test--leaf 'root "old")
'(:capability content)))
(root (tp-object-resolve surface '(root)))
candidate)
(should-error
(tp-surface-update
surface
(lambda (context)
(tp-object-ensure context nil 'root 'text)
(setq candidate (tp-object-ensure context root 'orphan 'text))
(tp-surface-test--leaf 'root "new")))
:type 'tp-orphan-object)
(should (equal (buffer-string) "old"))
(should (eq root (tp-object-resolve surface '(root))))
(should-not (tp-object-live-p candidate)))))
(ert-deftest tp-surface-test-properties-capability-rejects-text ()
"A properties-only mount cannot replace host text."
(tp-surface-test--with-buffer
(insert "host")
(should-error
(tp-surface-mount buffer (tp-surface-test--leaf 'root "replacement")
'(:capability properties))
:type 'tp-capability-error)
(should (equal (buffer-string) "host"))))
(ert-deftest tp-surface-test-range-anchor-follows-edits-before-it ()
"A host insertion before an anchor moves its property contribution."
(tp-surface-test--with-buffer
(insert "012345")
(let* ((anchor (tp-range-anchor-create buffer 3 5))
(value "A")
(producer
(lambda (context)
(let ((object (tp-object-ensure context nil 'root 'range)))
(tp-object-attach-range context object anchor))
(tp-surface-plan-create
:key 'root :kind 'range :props (list 'help-echo value)
:capability 'properties)))
(surface (tp-surface-mount
buffer producer '(:capability properties))))
(should (equal (get-text-property 3 'help-echo) "A"))
(goto-char 1)
(insert "X")
(setq value "B")
(tp-surface-update surface producer)
(should (equal (get-text-property 4 'help-echo) "B"))
(should-not (get-text-property 3 'help-echo)))))
(ert-deftest tp-surface-test-property-conflict-needs-explicit-rebase ()
"TP preserves an external property write until the anchor is rebased."
(tp-surface-test--with-buffer
(insert "host")
(let* ((anchor (tp-range-anchor-create buffer 1 5))
(value "A")
(producer
(lambda (context)
(let ((object (tp-object-ensure context nil 'root 'range)))
(tp-object-attach-range context object anchor))
(tp-surface-plan-create
:key 'root :kind 'range :props (list 'help-echo value)
:capability 'properties)))
(surface (tp-surface-mount
buffer producer '(:capability properties)))
(revision (tp-surface-revision surface)))
(put-text-property 1 5 'help-echo "external")
(setq value "B")
(should-error (tp-surface-update surface producer)
:type 'tp-property-conflict)
(should (= (tp-surface-revision surface) revision))
(should (equal (get-text-property 2 'help-echo) "external"))
(tp-range-rebase anchor)
(tp-surface-update surface producer)
(should (equal (get-text-property 2 'help-echo) "B"))
(tp-surface-unmount surface)
(should (equal (get-text-property 2 'help-echo) "external")))))
(ert-deftest tp-surface-test-unmount-preserves-conflicting-host-value ()
"Unmount removes only TP's still-current property contribution."
(tp-surface-test--with-buffer
(insert "host")
(let* ((anchor (tp-range-anchor-create buffer 1 5))
(producer
(lambda (context)
(let ((object (tp-object-ensure context nil 'root 'range)))
(tp-object-attach-range context object anchor))
(tp-surface-plan-create
:key 'root :kind 'range :props '(help-echo "tp")
:capability 'properties)))
(surface (tp-surface-mount
buffer producer '(:capability properties))))
(put-text-property 1 5 'help-echo "external")
(let ((report (tp-surface-unmount surface)))
(should (plist-get report :property-conflicts)))
(should (equal (get-text-property 2 'help-echo) "external"))
(should-not (tp-surface-live-p surface))
(should-not (tp-range-anchor-live-p anchor)))))
(ert-deftest tp-surface-test-content-external-edit-marks-mount-stale ()
"An external edit inside content-owned text prevents silent overwrite."
(tp-surface-test--with-buffer
(let ((surface
(tp-surface-mount buffer (tp-surface-test--leaf 'root "abc")
'(:capability content))))
(goto-char 2)
(insert "X")
(should-error
(tp-surface-update surface (tp-surface-test--leaf 'root "next"))
:type 'tp-stale-mount)
(should (equal (buffer-string) "aXbc")))))
(ert-deftest tp-surface-test-publication-steps-roll-back-exactly ()
"Failure at each publication step keeps the prior surface revision."
(dolist (step '(text property marker index client-state))
(tp-surface-test--with-buffer
(let* ((surface
(tp-surface-mount buffer (tp-surface-test--leaf 'root "old")
'(:capability content)))
(revision (tp-surface-revision surface))
(object (tp-object-resolve surface '(root)))
(tp--surface-publication-step-function
(lambda (current _surface)
(when (eq current step) (error "Injected %s failure" step)))))
(should-error
(tp-surface-update
surface
(tp-surface-result-create
(tp-surface-test--leaf 'root "new" '(face bold))
(list :candidate step))))
(should (equal-including-properties (buffer-string) "old"))
(should (= (tp-surface-revision surface) revision))
(should (eq object (tp-object-resolve surface '(root))))))))
(ert-deftest tp-surface-test-global-signal-update-is-multi-surface-atomic ()
"A second-surface failure rolls back buffers, bindings, and source value."
(let* ((signal (tp-signal-create 1))
(first-buffer (generate-new-buffer " *tp-surface-first*"))
(second-buffer (generate-new-buffer " *tp-surface-second*"))
(producer (tp-surface-test--producer signal))
first second)
(unwind-protect
(progn
(setq first (tp-surface-mount
first-buffer producer '(:capability content))
second (tp-surface-mount
second-buffer producer '(:capability content)))
(let ((first-revision (tp-surface-revision first))
(second-revision (tp-surface-revision second))
(tp--surface-publication-step-function
(lambda (step surface)
(when (and (eq step 'client-state) (eq surface second))
(error "Injected second-surface failure")))))
(should-error (tp-signal-set signal 2))
(should (= (tp-signal-peek signal) 1))
(should (= (tp-surface-revision first) first-revision))
(should (= (tp-surface-revision second) second-revision))
(should (equal (tp-surface-client-state first) '(:value 1)))
(should (equal (tp-surface-client-state second) '(:value 1)))
(with-current-buffer first-buffer (should (equal (buffer-string) "1")))
(with-current-buffer second-buffer (should (equal (buffer-string) "1")))))
(when (buffer-live-p first-buffer) (kill-buffer first-buffer))
(when (buffer-live-p second-buffer) (kill-buffer second-buffer)))))
(ert-deftest tp-surface-test-materialize-matches-first-content-mount ()
"Pure and live publication produce identical propertized text."
(tp-surface-test--with-buffer
(let* ((child (tp-surface-test--leaf
'child "text" '(face (:foreground "white"))))
(plan (tp-surface-plan-create
:key 'root :kind 'group :children (list child)
:props '(help-echo "root") :capability 'content))
(materialized (tp-surface-materialize-string plan)))
(tp-surface-mount buffer plan '(:capability content))
(should (equal-including-properties materialized (buffer-string))))))
(ert-deftest tp-surface-test-index-diagnostics-use-side-state ()
"Point queries and reports resolve through the retained side index."
(tp-surface-test--with-buffer
(let* ((child (tp-surface-test--leaf 'child "x"))
(plan (tp-surface-plan-create
:key 'root :kind 'group :children (list child)
:tags '(:role root) :capability 'content))
(surface (tp-surface-mount buffer plan '(:capability content)))
(objects (tp-surface-at-point 1 buffer))
(inspection (tp-surface-inspect surface)))
(should (= (length objects) 2))
(should (eq (plist-get inspection :surface) surface))
(should (= (plist-get inspection :revision) 1))
(should (equal (plist-get (tp-surface-report surface) :surface-id)
(plist-get inspection :id))))))
(ert-deftest tp-surface-test-kill-buffer-disposes-runtime ()
"Killing the lifecycle owner releases its surface and objects."
(let* ((buffer (generate-new-buffer " *tp-surface-kill*"))
(surface (tp-surface-mount
buffer (tp-surface-test--leaf 'root "x")
'(:capability content)))
(object (tp-object-resolve surface '(root))))
(kill-buffer buffer)
(should-not (tp-surface-live-p surface))
(should-not (tp-object-live-p object))))
(ert-deftest tp-surface-test-omitted-binding-defaults-to-deletion ()
"A surviving object does not retain an omitted binding by accident."
(tp-surface-test--with-buffer
(let ((signal (tp-signal-create 1))
(include t)
binding)
(let* ((producer
(lambda (context)
(let ((object (tp-object-ensure context nil 'root 'text)))
(when include
(setq binding
(tp-bind object '(test . optional)
(lambda () (tp-signal-read signal))))
(tp-binding-read binding)))
(tp-surface-test--leaf 'root "value")))
(surface (tp-surface-mount
buffer producer '(:capability content))))
(should (= (tp-signal-subscriber-count signal) 1))
(setq include nil)
(tp-surface-update surface producer)
(should-not (tp-binding-live-p binding))
(should (= (tp-signal-subscriber-count signal) 0))))))
(ert-deftest tp-surface-test-explicit-retain-keeps-omitted-binding ()
"An explicit retain lifecycle keeps an omitted computation subscribed."
(tp-surface-test--with-buffer
(let ((signal (tp-signal-create 1))
(include t)
binding)
(let* ((producer
(lambda (context)
(let ((object (tp-object-ensure context nil 'root 'text)))
(when include
(setq binding
(tp-bind object '(test . retained)
(lambda () (tp-signal-read signal))
:lifecycle 'retain))
(tp-binding-read binding)))
(tp-surface-test--leaf 'root "value")))
(surface (tp-surface-mount
buffer producer '(:capability content))))
(setq include nil)
(tp-surface-update surface producer)
(should (tp-binding-live-p binding))
(should (= (tp-signal-subscriber-count signal) 1))))))
(ert-deftest tp-surface-test-read-only-and-narrowing-policy-is-explicit ()
"Content publication requires opt-in for read-only buffers and preserves narrowing."
(tp-surface-test--with-buffer
(insert "012345")
(narrow-to-region 2 5)
(setq buffer-read-only t)
(should-error
(tp-surface-mount buffer (tp-surface-test--leaf 'root "x")
'(:capability content :start 2 :end 5))
:type 'buffer-read-only)
(let ((surface
(tp-surface-mount
buffer (tp-surface-test--leaf 'root "x")
'(:capability content :start 2 :end 5 :inhibit-read-only t))))
(should buffer-read-only)
(should (buffer-narrowed-p))
(should (equal (buffer-substring-no-properties (point-min) (point-max))
"x"))
(tp-surface-unmount surface))))
(ert-deftest tp-surface-test-observer-failure-does-not-roll-back ()
"Observer errors are recorded after a successful publication."
(tp-surface-test--with-buffer
(let ((surface
(tp-surface-mount
buffer (tp-surface-test--leaf 'root "committed")
(list :capability 'content
:observers (list (lambda (_surface _report)
(error "Observer failure")))))))
(should (equal (buffer-string) "committed"))
(should (= (tp-surface-revision surface) 1))
(should (= (length (plist-get (tp-surface-report surface)
:observer-errors))
1)))))
(ert-deftest tp-surface-test-observer-write-starts-a-new-transaction ()
"An observer signal write runs after the publishing transaction exits."
(tp-surface-test--with-buffer
(let* ((signal (tp-signal-create 1))
(write-once t)
(producer (tp-surface-test--producer signal))
(surface
(tp-surface-mount
buffer producer
(list :capability 'content
:observers
(list (lambda (_surface _report)
(when write-once
(setq write-once nil)
(tp-signal-set signal 2))))))))
(should (= (tp-signal-peek signal) 2))
(should (equal (buffer-string) "2"))
(should (= (tp-surface-revision surface) 2)))))
(ert-deftest tp-surface-test-equal-reactive-plan-skips-surface-publication ()
"An equal producer result leaves the surface revision unchanged."
(tp-surface-test--with-buffer
(let* ((signal (tp-signal-create 10))
(producer
(lambda (context)
(tp-object-ensure context nil 'root 'text)
(tp-surface-test--leaf
'root (number-to-string (/ (tp-signal-read signal) 10)))))
(surface (tp-surface-mount
buffer producer '(:capability content)))
(revision (tp-surface-revision surface)))
(tp-signal-set signal 11)
(should (= (tp-surface-revision surface) revision))
(should (equal (buffer-string) "1")))))
(ert-deftest tp-surface-test-keyed-reorder-preserves-object-handles ()
"Keyed children keep identity when their display order changes."
(tp-surface-test--with-buffer
(let* ((first
(tp-surface-plan-create
:key 'root :kind 'group
:children (list (tp-surface-test--leaf 'a "A")
(tp-surface-test--leaf 'b "B"))
:capability 'content))
(surface (tp-surface-mount buffer first '(:capability content)))
(a (tp-object-resolve surface '(root a)))
(b (tp-object-resolve surface '(root b)))
(second
(tp-surface-plan-create
:key 'root :kind 'group
:children (list (tp-surface-test--leaf 'b "B")
(tp-surface-test--leaf 'a "A"))
:capability 'content)))
(tp-surface-update surface second)
(should (equal (buffer-string) "BA"))
(should (eq a (tp-object-resolve surface '(root a))))
(should (eq b (tp-object-resolve surface '(root b))))
(should (= (plist-get (tp-surface-report surface) :moved-objects) 2)))))
(ert-deftest tp-surface-test-overlapping-range-contributions-are-ordered ()
"Overlapping property mounts combine deterministically by plan order."
(tp-surface-test--with-buffer
(insert "abcd")
(let* ((left (tp-range-anchor-create buffer 1 4))
(right (tp-range-anchor-create buffer 2 5))
(producer
(lambda (context)
(let* ((root (tp-object-ensure context nil 'root 'group))
(a (tp-object-ensure context root 'a 'range))
(b (tp-object-ensure context root 'b 'range)))
(tp-object-attach-range context a left)
(tp-object-attach-range context b right))
(tp-surface-plan-create
:key 'root :kind 'group :capability 'properties
:children
(list (tp-surface-plan-create
:key 'a :kind 'range :props '(help-echo "A")
:capability 'properties)
(tp-surface-plan-create
:key 'b :kind 'range :props '(help-echo "B")
:capability 'properties)))))
(surface (tp-surface-mount
buffer producer '(:capability properties))))
(should (equal (get-text-property 1 'help-echo) "A"))
(should (equal (get-text-property 2 'help-echo) "B"))
(should (equal (get-text-property 4 'help-echo) "B"))
(tp-surface-unmount surface)
(should-not (get-text-property 2 'help-echo)))))
(ert-deftest tp-surface-test-explicit-nil-is-a-property-contribution ()
"A present nil contribution hides and later restores its host baseline."
(tp-surface-test--with-buffer
(insert "host")
(put-text-property 1 5 'help-echo "baseline")
(let* ((anchor (tp-range-anchor-create buffer 1 5))
(producer
(lambda (context)
(let ((object (tp-object-ensure context nil 'root 'range)))
(tp-object-attach-range context object anchor))
(tp-surface-plan-create
:key 'root :kind 'range :props '(help-echo nil)
:capability 'properties)))
(surface (tp-surface-mount
buffer producer '(:capability properties))))
(should (equal (tp--property-state-at buffer 2 'help-echo)
'(t)))
(tp-surface-unmount surface)
(should (equal (get-text-property 2 'help-echo) "baseline")))))
(ert-deftest tp-surface-test-unmount-preserves-only-conflicting-subranges ()
"Unmount restores owned runs without clobbering a partial host override."
(tp-surface-test--with-buffer
(insert "abcd")
(let* ((anchor (tp-range-anchor-create buffer 1 5))
(producer
(lambda (context)
(let ((object (tp-object-ensure context nil 'root 'range)))
(tp-object-attach-range context object anchor))
(tp-surface-plan-create
:key 'root :kind 'range :props '(help-echo "tp")
:capability 'properties)))
(surface (tp-surface-mount
buffer producer '(:capability properties))))
(put-text-property 2 3 'help-echo "external")
(tp-surface-unmount surface)
(should-not (get-text-property 1 'help-echo))
(should (equal (get-text-property 2 'help-echo) "external"))
(should-not (get-text-property 3 'help-echo)))))
(ert-deftest tp-surface-test-cross-surface-overlap-is-rejected ()
"Independent surfaces cannot silently claim the same character range."
(tp-surface-test--with-buffer
(let ((first (tp-surface-mount
buffer (tp-surface-test--leaf 'first "owned")
'(:capability content))))
(should-error
(tp-surface-mount
buffer (tp-surface-test--leaf 'second "overlap")
'(:capability content))
:type 'tp-capability-error)
(should (tp-surface-live-p first))
(should (equal (buffer-string) "owned")))))
(ert-deftest tp-surface-test-kill-during-publication-is-authoritative ()
"A killed target stays dead while other surfaces and sources roll back."
(let* ((signal (tp-signal-create 1))
(first-buffer (generate-new-buffer " *tp-surface-survivor*"))
(victim-buffer (generate-new-buffer " *tp-surface-victim*"))
(producer (tp-surface-test--producer signal))
first victim victim-object)
(unwind-protect
(progn
(setq first (tp-surface-mount
first-buffer producer '(:capability content))
victim (tp-surface-mount
victim-buffer producer '(:capability content))
victim-object (tp-object-resolve victim '(root)))
(let ((revision (tp-surface-revision first))
(tp--surface-publication-step-function
(lambda (step surface)
(when (and (eq step 'text) (eq surface victim))
(kill-buffer victim-buffer)))))
(should-error (tp-signal-set signal 2))
(should (= (tp-signal-peek signal) 1))
(should (tp-surface-live-p first))
(should (= (tp-surface-revision first) revision))
(with-current-buffer first-buffer
(should (equal (buffer-string) "1")))
(should-not (buffer-live-p victim-buffer))
(should-not (tp-surface-live-p victim))
(should-not (tp-object-live-p victim-object))))
(when (buffer-live-p first-buffer) (kill-buffer first-buffer))
(when (buffer-live-p victim-buffer) (kill-buffer victim-buffer)))))
(provide 'tp-surface-tests)
;;; tp-surface-tests.el ends here

View File

@ -67,10 +67,19 @@
(defvar tp--transaction-binding-snapshots nil)
(defvar tp--transaction-created-bindings nil)
(defvar tp--transaction-recompute-counts nil)
(defvar tp--transaction-extensions nil)
(defvar tp--transaction-counter-start nil)
(defvar tp--transaction-after-commit-callbacks nil)
(defvar tp--current-binding nil)
(defvar tp--binding-compute-stack nil)
(defvar tp--collected-dependency-set nil)
(defvar tp--collected-dependencies nil)
(defvar tp--binding-touch-function nil)
(defvar tp--binding-changed-functions nil)
(defvar tp--transaction-publish-functions nil)
(defvar tp--transaction-rollback-functions nil)
(defvar tp--transaction-rollback-final-functions nil)
(defvar tp--transaction-committed-functions nil)
(defun tp--counter-increment (key)
"Increment reactive counter KEY."
@ -360,6 +369,8 @@ dirty target has recomputed."
(tp-binding-initialized-p binding) t
(tp-binding-revision binding)
(1+ (tp-binding-revision binding)))
(run-hook-with-args 'tp--binding-changed-functions
binding old value)
(tp--invalidate-subscribers binding requester))
(tp--counter-increment :skipped))
value)))
@ -433,6 +444,8 @@ EQUALITY compares values and LIFECYCLE controls retention."
(tp--register-binding binding)
(push binding tp--transaction-created-bindings)
(tp--enqueue-binding binding))
(when tp--binding-touch-function
(funcall tp--binding-touch-function binding))
binding))
(cl-defun tp-bind (owner key compute &key (equality #'equal) (lifecycle 'delete))
@ -490,6 +503,46 @@ or `retain'."
(lambda () (dolist (binding bindings) (tp--dispose-binding binding)))))
(length bindings)))
(defun tp-binding-owner-bindings (owner)
"Return OWNER's live bindings ordered by stable binding id."
(when-let ((table (tp--owner-binding-table owner)))
(let (bindings)
(maphash (lambda (_key binding) (push binding bindings)) table)
(sort bindings (lambda (left right)
(< (tp-binding-id left) (tp-binding-id right)))))))
(defun tp-binding-dispose (binding)
"Dispose BINDING and detach all of its graph edges."
(tp--validate-live-binding binding)
(if tp--transaction-active
(tp--dispose-binding binding)
(tp--call-with-transaction (lambda () (tp--dispose-binding binding))))
nil)
(defun tp--transaction-extension (key &optional create)
"Return transaction extension state for KEY.
When CREATE is non-nil, install and return a fresh hash table when absent."
(unless tp--transaction-active
(signal 'tp-reactive-error (list :outside-transaction key)))
(or (gethash key tp--transaction-extensions)
(when create
(let ((state (make-hash-table :test #'eq)))
(puthash key state tp--transaction-extensions)
state))))
(defun tp--transaction-counter-delta (key)
"Return active transaction counter delta for KEY."
(if tp--transaction-counter-start
(- (plist-get tp--reactive-counters key)
(plist-get tp--transaction-counter-start key))
0))
(defun tp--enqueue-after-commit (function)
"Run FUNCTION after the active outer transaction has exited."
(unless tp--transaction-active
(signal 'tp-reactive-error (list :outside-transaction function)))
(push function tp--transaction-after-commit-callbacks))
(defun tp--dequeue-dirty-binding ()
"Return and remove the next queued dirty binding."
(let (binding)
@ -554,27 +607,46 @@ or `retain'."
"Call FUNCTION in one atomic signal and binding transaction."
(if tp--transaction-active
(funcall function)
(let ((tp--transaction-active t)
(tp--transaction-signal-values (make-hash-table :test #'eq))
(tp--transaction-signals nil)
(tp--transaction-dirty-set (make-hash-table :test #'eq))
(tp--transaction-dirty-queue nil)
(tp--transaction-binding-snapshots (make-hash-table :test #'eq))
(tp--transaction-created-bindings nil)
(tp--transaction-recompute-counts (make-hash-table :test #'eq))
(counter-snapshot (copy-sequence tp--reactive-counters))
success result)
(unwind-protect
(progn
(setq result (funcall function))
(tp--flush-dirty-bindings)
(tp--commit-signal-values)
(setq success t)
result)
(unless success
(let ((inhibit-quit t))
(tp--rollback-bindings)
(setq tp--reactive-counters counter-snapshot)))))))
(let (after-commit result)
(setq result
(let ((tp--transaction-active t)
(tp--transaction-signal-values
(make-hash-table :test #'eq))
(tp--transaction-signals nil)
(tp--transaction-dirty-set (make-hash-table :test #'eq))
(tp--transaction-dirty-queue nil)
(tp--transaction-binding-snapshots
(make-hash-table :test #'eq))
(tp--transaction-created-bindings nil)
(tp--transaction-recompute-counts
(make-hash-table :test #'eq))
(tp--transaction-extensions (make-hash-table :test #'eq))
(tp--transaction-after-commit-callbacks nil)
(counter-snapshot (copy-sequence tp--reactive-counters))
(tp--transaction-counter-start nil)
success transaction-result)
(setq tp--transaction-counter-start counter-snapshot)
(unwind-protect
(progn
(setq transaction-result (funcall function))
(tp--flush-dirty-bindings)
(run-hooks 'tp--transaction-publish-functions)
(tp--commit-signal-values)
(setq success t)
transaction-result)
(unless success
(let ((inhibit-quit t))
(run-hooks 'tp--transaction-rollback-functions)
(tp--rollback-bindings)
(setq tp--reactive-counters counter-snapshot)
(run-hooks 'tp--transaction-rollback-final-functions))))
(when success
(run-hooks 'tp--transaction-committed-functions)
(setq after-commit
(nreverse tp--transaction-after-commit-callbacks)))
transaction-result))
(dolist (callback after-commit) (funcall callback))
result)))
;;;###autoload
(defmacro tp-with-transaction (&rest body)

1938
tp-surface.el Normal file

File diff suppressed because it is too large Load Diff

3
tp.el
View File

@ -26,6 +26,8 @@
;; custom properties, and explicit computed values.
;; tp-reactive.el Exact signals, bindings, transactions, scoped variable
;; adapters, plus temporary legacy layer watcher state.
;; tp-surface.el Retained plans, objects, range anchors, mounts, indexes,
;; and atomic buffer publication.
;; tp-layer.el Layer registry: `define-tp', `define-tps',
;; layer/group resolution and expansion.
;; tp-ops.el Core primitives: `tp-set', `tp-reset', `tp-add',
@ -54,6 +56,7 @@
(require 'tp-core)
(require 'tp-style)
(require 'tp-reactive)
(require 'tp-surface)
(require 'tp-layer)
(require 'tp-ops)
(require 'tp-search)