diff --git a/CHANGELOG.md b/CHANGELOG.md index 895fc2b..bbb95eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the tp library are documented here. ### Added +- 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: `tp--native-range`, `tp--presence`, `tp--request`, `tp--match`, diff --git a/README.md b/README.md index 66b25af..8f847ac 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ Since 0.2.0 the library is organized as a family of layered modules (`tp-core`, The TP 1.0 migration has begun with a pure schema-driven cascade kernel. `tp-style.el` adds namespaced property schemas, structured selectors, origin/importance/layer/specificity/scope/source-order precedence, property-specific inheritance, tagged CSS-wide values, inherited custom properties, explicit `tp-computed` sources, projection to Emacs properties, and read-only winner provenance. It deliberately owns no buffers, markers, mounts, or reactive subscriptions; retained surfaces will build on this kernel in later migration phases. +`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. + ### Core Innovations 1. **Unified API Parameter Conventions**: All functions support multiple flexible calling patterns, working seamlessly with both strings and buffers @@ -432,7 +434,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` | Reactive dependency registry, variable watchers, batching queue | +| `tp-reactive.el` | Exact signals, memoized bindings, transactions, scoped variable adapters, and temporary legacy watcher state | | `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 | @@ -515,6 +517,19 @@ A complete overview of all tp.el functions organized by category: | `tp-compute-style` / `tp-project-style` | Compute canonical values/provenance and project final Emacs text properties | | `tp-style-reset-rules` / `tp-style-reset` | Reset stylesheet-only state or the complete style registry | +#### Signals and Bindings + +| Function | Description | +|----------|-------------| +| `tp-signal-create` / `tp-signal-read` / `tp-signal-set` | Create, dependency-track, and transactionally update a reactive source | +| `tp-signal-peek` / `tp-signal-live-p` / `tp-signal-subscriber-count` / `tp-signal-dispose` | Inspect or explicitly end signal lifecycle without collecting a dependency | +| `tp-bind` / `tp-binding-read` | Idempotently install a memoized owner+key computation and read it as a dependency | +| `tp-binding-live-p` / `tp-binding-dependency-count` / `tp-binding-subscriber-count` | Inspect binding lifecycle and exact graph degree | +| `tp-binding-dispose-owner` | Remove an owner's bindings and all graph edges | +| `tp-with-transaction` | Batch candidate writes and publish one deduplicated dirty closure atomically | +| `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 | + #### Property Layer Definition Functions | Function | Description | |----------|-------------| diff --git a/README_CN.md b/README_CN.md index 7b62986..ad0e3d1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -175,6 +175,8 @@ TP 1.0 迁移已经从纯 schema-driven cascade kernel 开始。`tp-style.el` 新增 namespaced property schema、结构化 selector、origin/importance/layer/specificity/scope/source-order 优先级、逐属性继承、带 tag 的 CSS-wide 值、可继承 custom property、显式 `tp-computed` source、Emacs 属性投影和只读 winner provenance。这个模块刻意不拥有 buffer、marker、mount 或响应式订阅;后续 retained surface 会建立在同一个 kernel 上。 +`tp-reactive.el` 现在也提供 TP 1.0 的精确依赖 runtime:signal 只 invalidates 真实订阅的 binding,binding 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。 + ### 核心创新 1. **统一的 API 参数规范**:所有函数支持多种灵活的调用方式,同时适用于字符串和缓冲区 @@ -428,7 +430,7 @@ OBJECT 总是位于最后(nil 表示当前缓冲区)。所有核心函数和 |---|---| | `tp-core.el` | 区间、plist/face 合并引擎、调试日志、`$var` 工具 | | `tp-style.el` | namespaced property schema、结构化 selector、cascade、custom property 和显式 computed value | -| `tp-reactive.el` | 响应式依赖注册表、变量监视器、批量更新队列 | +| `tp-reactive.el` | 精确 signal、memoized binding、transaction、scoped variable adapter 和临时 legacy watcher state | | `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`、导航 | @@ -510,6 +512,19 @@ tp.el 所有函数按类别组织的完整概览: | `tp-compute-style` / `tp-project-style` | 计算 canonical values/provenance 并投影最终 Emacs text properties | | `tp-style-reset-rules` / `tp-style-reset` | 仅重置 stylesheet state,或重置完整 style registry | +#### Signals 与 Bindings + +| 函数 | 描述 | +|------|------| +| `tp-signal-create` / `tp-signal-read` / `tp-signal-set` | 创建、依赖收集并 transactionally 更新 reactive source | +| `tp-signal-peek` / `tp-signal-live-p` / `tp-signal-subscriber-count` / `tp-signal-dispose` | 不收集依赖地检查或显式结束 signal lifecycle | +| `tp-bind` / `tp-binding-read` | 按 owner+key 幂等安装 memoized computation,并把读取登记为依赖 | +| `tp-binding-live-p` / `tp-binding-dependency-count` / `tp-binding-subscriber-count` | 检查 binding lifecycle 与精确 graph degree | +| `tp-binding-dispose-owner` | 删除 owner 的 bindings 及全部 graph edges | +| `tp-with-transaction` | 批量冻结 candidate write,并原子发布一次去重后的 dirty closure | +| `tp-variable-signal` | 把 global 或 buffer-local Elisp variable 适配为 scoped signal | +| `tp-reactive-counters` / `tp-reactive-reset-counters` | 读取或重置 public scheduler work counters | + #### 属性层定义函数 | 函数 | 描述 | |------|------| diff --git a/docs/API-SEMANTICS.md b/docs/API-SEMANTICS.md index fdb954f..34117ff 100644 --- a/docs/API-SEMANTICS.md +++ b/docs/API-SEMANTICS.md @@ -182,3 +182,13 @@ tp 不会把外部属性收编成新的匿名层,也不会静默覆盖它。 - `--name` custom property 默认继承;`tp-var` 支持 fallback 和 cycle invalidation。属性 schema 的 normalizer/validator 在变量与 wide value 求值后执行。 - `tp-compute-style` 返回 `tp-computed-style`,保存 canonical values、resolved custom properties 和可选 provenance;`tp-project-style` 是把 schema projector 汇总为最终 Emacs text properties 的唯一纯投影入口。 - 静态 `define-tp` 和静态 `define-tps` 生成层会同步编译为同名 canonical style;参数化或旧 `$var` 响应式定义不会冻结当前值为 style,待新的 signal/binding runtime 接管其动态 source。 + +## 13. Signals、bindings 与 transaction + +- `tp-signal-create` 创建 global 或 buffer-scoped source;`tp-signal-read` 仅在 binding compute context 中登记依赖,`tp-signal-peek` 永不登记依赖。buffer kill 会 dispose scoped signal;global signal 可用 `tp-signal-dispose` 显式结束 lifecycle,两者都会移除 subscriptions。 +- `tp-bind` 的 identity 是 owner object identity 加 caller-namespaced key;重复安装复用同一 binding,compute definition 变化才使它 dirty。`tp-binding-read` 读取 memoized value 并建立 binding→binding edge。 +- 每次成功 compute 以本次实际读取的依赖替换旧依赖;条件分支切换后旧 signal 不再触发。binding value 经其 equality comparator 判等,相等结果不 invalidates downstream。 +- signal write 先写入 transaction-local candidate state。最外层 `tp-with-transaction` 只遍历 exact dirty closure,去重并按 binding dependency 拓扑惰性求值;compute 内嵌 write 排队稳定,不递归执行。 +- 任一 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。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 673587d..aa7eb72 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,8 +1,8 @@ # tp 代码架构文档 -> 未来主版本目标: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。本文在最终切换完成前仍只描述 TP 0.3.x 当前实现。 +> 未来主版本目标: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 已在 `tp-style.el` 落地;retained surface 与新 reactive runtime 尚未切换。 +本文档描述 tp 库的模块分层结构与函数调用层次,从底层基础模块到上层功能模块的分层组织。TP 1.0 的纯 style/cascade kernel 与 exact signal/binding graph 已分别在 `tp-style.el`、`tp-reactive.el` 落地;retained surface 尚未切换。 当前 API 的规范契约见 [API-SEMANTICS.md](API-SEMANTICS.md);Emacs 原生 文本属性覆盖范围、已确认问题与演进路线见 @@ -177,7 +177,9 @@ Stage 2 canonical façade 是内部模型,不改变公开入口和历史返回 ### tp-reactive.el:响应式基础设施 -只依赖 tp-core。维护响应式依赖注册表、变量监听器、批量更新队列与 0.3.0 新增的**层→缓冲区注册表**;**不包含任何渲染逻辑**,重渲染通过钩子变量委托给 tp-render.el。批量更新的队列(`tp--batch-update-pending`、`tp--queue-batch-update`)定义在这里,但 `tp-with-batch-updates` 宏与刷新逻辑自 0.3.0 起位于 tp-render.el。 +只依赖 tp-core。文件上半部是 TP 1.0 的唯一新响应式执行语义:global/buffer-scoped signal、owner+key binding identity、dynamic dependency collection、binding→binding graph、transaction-local candidate signal values、dirty dedupe/topological lazy flush、nested write stabilization、rollback、cycle path、owner disposal、variable adapter 和 public counters。正常新热路径只从 source subscriber set 到 dirty binding,不读取 `buffer-list`、文本上的 `tp-name`/`tp-layers` 或 layer→buffer registry。 + +文件下半部仍暂存 0.3 façade 所需的变量→layer watcher、batch queue 和 layer→buffer registry,以保持旧测试在 retained surface 建成前可运行;它们没有被新 graph 调用,并将在最终 cutover phase 与 `tp-render.el` 扫描路径一起删除。这个暂存区不是第二套长期 public runtime。 #### 依赖注册与管理 | 函数/变量 | 描述 | @@ -571,6 +573,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 graph;normal update 不扫描 buffer | `tp-reactive-reset`;buffer-scoped signal 随 buffer kill | | 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` | diff --git a/tests/tp-binding-tests.el b/tests/tp-binding-tests.el new file mode 100644 index 0000000..9244618 --- /dev/null +++ b/tests/tp-binding-tests.el @@ -0,0 +1,315 @@ +;;; tp-binding-tests.el --- Tests for TP binding graph -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Geekinney + +;;; Commentary: + +;; Contract tests for TP 1.0 signals, bindings, and transactions. + +;;; Code: + +(require 'ert) +(require 'tp-reactive) + +(defvar tp-binding-test-variable nil + "Variable used by scoped signal adapter tests.") + +(defmacro tp-binding-test--isolated (&rest body) + "Run BODY with an isolated reactive runtime." + (declare (indent 0) (debug t)) + `(progn + (tp-reactive-reset) + (unwind-protect (progn ,@body) + (tp-reactive-reset)))) + +(ert-deftest tp-binding-test-signal-invalidates-only-direct-subscribers () + "A sparse signal write never visits unrelated bindings." + (tp-binding-test--isolated + (let ((target (tp-signal-create 0)) + (unrelated (tp-signal-create 0)) + (target-calls 0) + (unrelated-calls 0)) + (tp-with-transaction + (tp-bind 'target-owner '(test . value) + (lambda () + (cl-incf target-calls) + (tp-signal-read target))) + (dotimes (index 9999) + (tp-bind (list 'unrelated-owner index) '(test . value) + (lambda () + (cl-incf unrelated-calls) + (tp-signal-read unrelated))))) + (should (= target-calls 1)) + (should (= unrelated-calls 9999)) + (tp-reactive-reset-counters) + (cl-letf (((symbol-function 'buffer-list) + (lambda (&rest _) (error "Legacy scan"))) + ((symbol-function 'text-property-search-forward) + (lambda (&rest _) (error "Legacy scan"))) + ((symbol-function 'tp-reactive--buffer-layer-names) + (lambda (&rest _) (error "Legacy scan"))) + ((symbol-function 'tp-reactive-layer-buffers) + (lambda (&rest _) (error "Legacy scan")))) + (tp-signal-set target 1)) + (should (= target-calls 2)) + (should (= unrelated-calls 9999)) + (should (equal (tp-reactive-counters) + '(:invalidated 1 :recomputed 1 :skipped 0 + :subscription-added 0 :subscription-removed 0)))))) + +(ert-deftest tp-binding-test-conditional-dependencies-rewire () + "A binding unsubscribes from the branch it no longer reads." + (tp-binding-test--isolated + (let ((enabled (tp-signal-create t)) + (active (tp-signal-create 'active)) + (disabled (tp-signal-create 'disabled)) + (calls 0)) + (tp-bind 'owner '(test . branch) + (lambda () + (cl-incf calls) + (if (tp-signal-read enabled) + (tp-signal-read active) + (tp-signal-read disabled)))) + (tp-signal-set enabled nil) + (should (= calls 2)) + (tp-signal-set active 'ignored) + (should (= calls 2)) + (tp-signal-set disabled 'changed) + (should (= calls 3))))) + +(ert-deftest tp-binding-test-equal-signal-write-is-noop () + "Setting an equal signal value does not dirty its binding." + (tp-binding-test--isolated + (let ((source (tp-signal-create '(1 2) :equality #'equal)) + (calls 0)) + (tp-bind 'owner '(test . value) + (lambda () + (cl-incf calls) + (tp-signal-read source))) + (tp-reactive-reset-counters) + (tp-signal-set source (list 1 2)) + (should (= calls 1)) + (should (equal (tp-reactive-counters) + '(:invalidated 0 :recomputed 0 :skipped 0 + :subscription-added 0 :subscription-removed 0)))))) + +(ert-deftest tp-binding-test-transaction-deduplicates-writes () + "Repeated writes in one transaction recompute each binding once." + (tp-binding-test--isolated + (let ((source (tp-signal-create 0)) + (calls 0)) + (tp-bind 'owner '(test . value) + (lambda () + (cl-incf calls) + (tp-signal-read source))) + (tp-reactive-reset-counters) + (tp-with-transaction + (dotimes (value 100) + (tp-signal-set source (1+ value)))) + (should (= (tp-signal-peek source) 100)) + (should (= calls 2)) + (should (= (plist-get (tp-reactive-counters) :recomputed) 1))))) + +(ert-deftest tp-binding-test-nested-write-queues-a-second-pass () + "A compute write queues stabilization instead of recursing." + (tp-binding-test--isolated + (let ((source (tp-signal-create 0)) + (calls 0)) + (let ((binding + (tp-bind 'owner '(test . stabilizing) + (lambda () + (cl-incf calls) + (let ((value (tp-signal-read source))) + (when (zerop value) + (tp-signal-set source 1)) + value))))) + (should (= (tp-binding-read binding) 1)) + (should (= (tp-signal-peek source) 1)) + (should (= calls 2)))))) + +(ert-deftest tp-binding-test-chain-stops-at-equal-computed-value () + "An equal intermediate value prevents downstream recomputation." + (tp-binding-test--isolated + (let* ((source (tp-signal-create 10)) + (middle-calls 0) + (leaf-calls 0) + (middle + (tp-bind 'middle-owner '(test . quotient) + (lambda () + (cl-incf middle-calls) + (/ (tp-signal-read source) 10)))) + (_leaf + (tp-bind 'leaf-owner '(test . display) + (lambda () + (cl-incf leaf-calls) + (format "%s" (tp-binding-read middle)))))) + (tp-signal-set source 11) + (should (= middle-calls 2)) + (should (= leaf-calls 1)) + (tp-signal-set source 20) + (should (= middle-calls 3)) + (should (= leaf-calls 2))))) + +(ert-deftest tp-binding-test-owner-disposal-cleans-graph-edges () + "Disposing an owner removes all incoming and outgoing subscriptions." + (tp-binding-test--isolated + (let* ((source (tp-signal-create 1)) + (owner (list 'owner)) + (base (tp-bind owner '(test . base) + (lambda () (tp-signal-read source)))) + (derived (tp-bind owner '(test . derived) + (lambda () (1+ (tp-binding-read base)))))) + (should (= (tp-signal-subscriber-count source) 1)) + (should (= (tp-binding-subscriber-count base) 1)) + (should (= (tp-binding-dependency-count derived) 1)) + (tp-binding-dispose-owner owner) + (should (= (tp-signal-subscriber-count source) 0)) + (should (= (tp-binding-subscriber-count base) 0)) + (should-not (tp-binding-live-p base)) + (should-not (tp-binding-live-p derived))))) + +(ert-deftest tp-binding-test-failed-compute-rolls-back-values-and-dependencies () + "A failed transaction restores signal, binding, and dependency state." + (tp-binding-test--isolated + (let* ((switch (tp-signal-create t)) + (left (tp-signal-create 10)) + (right (tp-signal-create 20)) + (binding + (tp-bind 'owner '(test . branch) + (lambda () + (if (tp-signal-read switch) + (tp-signal-read left) + (progn + (tp-signal-read right) + (error "Broken branch"))))))) + (let ((counters-before (tp-reactive-counters))) + (should-error (tp-signal-set switch nil) :type 'error) + (should (equal (tp-reactive-counters) counters-before))) + (should (tp-signal-peek switch)) + (should (= (tp-binding-read binding) 10)) + (should (= (tp-signal-subscriber-count left) 1)) + (should (= (tp-signal-subscriber-count right) 0)) + (tp-signal-set left 11) + (should (= (tp-binding-read binding) 11))))) + +(ert-deftest tp-binding-test-failed-new-binding-is-unregistered () + "A failed initial compute invalidates and unregisters the new binding." + (tp-binding-test--isolated + (let (failed) + (should-error + (tp-bind 'owner '(test . failing) + (lambda () + (setq failed tp--current-binding) + (error "Initial failure")))) + (should-not (tp-binding-live-p failed)) + (let ((replacement + (tp-bind 'owner '(test . failing) (lambda () 42)))) + (should-not (eq failed replacement)) + (should (= (tp-binding-read replacement) 42)))))) + +(ert-deftest tp-binding-test-cycle-error-reports-binding-path () + "A binding dependency cycle reports the keys in cycle order." + (tp-binding-test--isolated + (let ((switch (tp-signal-create nil)) first second) + (setq first + (tp-bind 'first-owner '(test . first) + (lambda () + (if (tp-signal-read switch) + (tp-binding-read second) + 1)))) + (setq second + (tp-bind 'second-owner '(test . second) + (lambda () (1+ (tp-binding-read first))))) + (let ((failure + (should-error (tp-signal-set switch t) + :type 'tp-binding-cycle))) + (should + (equal (cadr failure) + '((test . first) (test . second) (test . first))))) + (should-not (tp-signal-peek switch)) + (should (= (tp-binding-read first) 1)) + (should (= (tp-binding-read second) 2))))) + +(ert-deftest tp-binding-test-dirty-target-can-break-an-old-cycle-edge () + "A dirty target rewires before cycle validation examines its old edges." + (tp-binding-test--isolated + (let ((first-mode (tp-signal-create nil)) + (second-mode (tp-signal-create t)) + first second) + (setq first + (tp-bind 'first-owner '(test . first) + (lambda () + (if (tp-signal-read first-mode) + (tp-binding-read second) + 1)))) + (setq second + (tp-bind 'second-owner '(test . second) + (lambda () + (if (tp-signal-read second-mode) + (tp-binding-read first) + 2)))) + (tp-with-transaction + (tp-signal-set second-mode nil) + (tp-signal-set first-mode t)) + (should (= (tp-binding-read first) 2)) + (should (= (tp-binding-read second) 2))))) + +(ert-deftest tp-binding-test-buffer-signal-dies-with-its-scope () + "Killing a buffer-local source detaches all subscriptions." + (tp-binding-test--isolated + (let* ((buffer (generate-new-buffer " *tp-binding-scope*")) + (signal (tp-signal-create 1 :scope buffer)) + (binding + (tp-bind 'owner '(test . local) + (lambda () (tp-signal-read signal))))) + (should (= (tp-binding-dependency-count binding) 1)) + (kill-buffer buffer) + (should-not (tp-signal-live-p signal)) + (should (= (tp-binding-dependency-count binding) 0))))) + +(ert-deftest tp-binding-test-global-signal-can-be-disposed-explicitly () + "Explicit disposal releases a global signal's graph edges." + (tp-binding-test--isolated + (let* ((signal (tp-signal-create 1)) + (binding + (tp-bind 'owner '(test . global) + (lambda () (tp-signal-read signal))))) + (tp-signal-dispose signal) + (should-not (tp-signal-live-p signal)) + (should (= (tp-signal-subscriber-count signal) 0)) + (should (= (tp-binding-dependency-count binding) 0))))) + +(ert-deftest tp-binding-test-variable-adapter-separates-global-and-buffer-scope () + "Variable adapters route global and buffer-local writes precisely." + (tp-binding-test--isolated + (let* ((symbol 'tp-binding-test-variable) + (buffer (generate-new-buffer " *tp-binding-variable*")) + (global-calls 0) + (local-calls 0)) + (unwind-protect + (progn + (set symbol 1) + (with-current-buffer buffer + (set (make-local-variable symbol) 10)) + (let ((global (tp-variable-signal symbol)) + (local (tp-variable-signal symbol buffer))) + (tp-bind 'global-owner '(test . global) + (lambda () + (cl-incf global-calls) + (tp-signal-read global))) + (tp-bind 'local-owner '(test . local) + (lambda () + (cl-incf local-calls) + (tp-signal-read local))) + (set symbol 2) + (should (= global-calls 2)) + (should (= local-calls 1)) + (with-current-buffer buffer + (set symbol 11)) + (should (= global-calls 2)) + (should (= local-calls 2)))) + (when (buffer-live-p buffer) (kill-buffer buffer)) + (makunbound symbol))))) + +(provide 'tp-binding-tests) +;;; tp-binding-tests.el ends here diff --git a/tp-reactive.el b/tp-reactive.el index 6666934..0742c6b 100644 --- a/tp-reactive.el +++ b/tp-reactive.el @@ -1,4 +1,4 @@ -;;; tp-reactive.el --- Reactive state storage and registration for tp -*- lexical-binding: t -*- +;;; tp-reactive.el --- Exact signals and binding scheduler -*- lexical-binding: t -*- ;; Copyright (C) 2024-2026 Geekinney @@ -11,18 +11,675 @@ ;;; Commentary: -;; Reactive core of tp: storage for variable dependencies, watchers, -;; computed properties and data variables; registration/unregistration; -;; the variable-watcher shell and the batching queue state. The -;; actual re-rendering of buffers - including the queue flush and the -;; public `tp-with-batch-updates' macro - lives in tp-render.el, which -;; installs itself via `tp--reactive-update-function'. +;; TP 1.0's exact signal-to-binding dependency graph, transaction-local +;; scheduler, scoped variable adapters, and rollback state. The lower legacy +;; section remains temporarily available to the 0.3 layer/render facade during +;; the staged cutover; new graph execution never calls its scan renderer. ;;; Code: (require 'cl-lib) (require 'tp-core) +(define-error 'tp-reactive-error "TP reactive runtime error") +(define-error 'tp-invalid-signal-scope "Invalid TP signal scope" + 'tp-reactive-error) +(define-error 'tp-disposed-signal "TP signal is disposed" 'tp-reactive-error) +(define-error 'tp-disposed-binding "TP binding is disposed" 'tp-reactive-error) +(define-error 'tp-binding-cycle "TP binding dependency cycle" + 'tp-reactive-error) + +(cl-defstruct (tp-signal (:constructor tp--make-signal)) + "Reactive source with a committed value and exact subscribers." + id committed-value equality subscribers scope adapter-key disposed revision) + +(cl-defstruct (tp-binding (:constructor tp--make-binding)) + "Memoized computation installed on one owner and namespaced key." + id owner key compute equality last-value initialized-p dependencies + subscribers dirty state revision lifecycle disposed) + +(cl-defstruct (tp--binding-snapshot + (:constructor tp--make-binding-snapshot)) + compute equality last-value initialized-p dependencies dirty state + revision lifecycle disposed) + +(defconst tp--reactive-absent (make-symbol "tp-reactive-absent")) + +(defvar tp--signal-id-counter 0) +(defvar tp--binding-id-counter 0) +(defvar tp--signals nil) +(defvar tp--bindings nil) +(defvar tp--owner-bindings (make-hash-table :test #'eq)) +(defvar tp--variable-signals (make-hash-table :test #'equal)) +(defvar tp--variable-signal-watched nil) + +(defvar-local tp--buffer-signals nil) + +(defvar tp--reactive-counters + (list :invalidated 0 :recomputed 0 :skipped 0 + :subscription-added 0 :subscription-removed 0)) + +(defvar tp--transaction-active nil) +(defvar tp--transaction-signal-values nil) +(defvar tp--transaction-signals nil) +(defvar tp--transaction-dirty-set nil) +(defvar tp--transaction-dirty-queue nil) +(defvar tp--transaction-binding-snapshots nil) +(defvar tp--transaction-created-bindings nil) +(defvar tp--transaction-recompute-counts nil) +(defvar tp--current-binding nil) +(defvar tp--binding-compute-stack nil) +(defvar tp--collected-dependency-set nil) +(defvar tp--collected-dependencies nil) + +(defun tp--counter-increment (key) + "Increment reactive counter KEY." + (setq tp--reactive-counters + (plist-put tp--reactive-counters key + (1+ (plist-get tp--reactive-counters key))))) + +(defun tp-reactive-counters () + "Return public signal and binding scheduler counters." + (copy-sequence tp--reactive-counters)) + +(defun tp-reactive-reset-counters () + "Reset public signal and binding scheduler counters." + (setq tp--reactive-counters + (list :invalidated 0 :recomputed 0 :skipped 0 + :subscription-added 0 :subscription-removed 0))) + +(defun tp--dependency-subscribers (dependency) + "Return the subscriber table owned by DEPENDENCY." + (cond ((tp-signal-p dependency) (tp-signal-subscribers dependency)) + ((tp-binding-p dependency) (tp-binding-subscribers dependency)) + (t (signal 'wrong-type-argument + (list '(or tp-signal-p tp-binding-p) dependency))))) + +(defun tp--subscription-add (dependency binding) + "Subscribe BINDING to DEPENDENCY." + (let ((subscribers (tp--dependency-subscribers dependency))) + (unless (gethash binding subscribers) + (puthash binding t subscribers) + (tp--counter-increment :subscription-added)))) + +(defun tp--subscription-remove (dependency binding) + "Unsubscribe BINDING from DEPENDENCY." + (let ((subscribers (tp--dependency-subscribers dependency))) + (when (gethash binding subscribers) + (remhash binding subscribers) + (tp--counter-increment :subscription-removed)))) + +(defun tp--sorted-subscribers (dependency) + "Return DEPENDENCY subscribers ordered by stable binding id." + (let (bindings) + (maphash (lambda (binding _present) (push binding bindings)) + (tp--dependency-subscribers dependency)) + (sort bindings (lambda (left right) + (< (tp-binding-id left) (tp-binding-id right)))))) + +(defun tp--binding-snapshot (binding) + "Return a rollback snapshot of BINDING." + (tp--make-binding-snapshot + :compute (tp-binding-compute binding) + :equality (tp-binding-equality binding) + :last-value (tp-binding-last-value binding) + :initialized-p (tp-binding-initialized-p binding) + :dependencies (copy-sequence (tp-binding-dependencies binding)) + :dirty (tp-binding-dirty binding) + :state (tp-binding-state binding) + :revision (tp-binding-revision binding) + :lifecycle (tp-binding-lifecycle binding) + :disposed (tp-binding-disposed binding))) + +(defun tp--snapshot-binding (binding) + "Save BINDING once for the active transaction." + (when (and tp--transaction-active + (not (memq binding tp--transaction-created-bindings)) + (not (gethash binding tp--transaction-binding-snapshots))) + (puthash binding (tp--binding-snapshot binding) + tp--transaction-binding-snapshots))) + +(defun tp--owner-binding-table (owner &optional create) + "Return OWNER's binding table, creating it when CREATE is non-nil." + (or (gethash owner tp--owner-bindings) + (when create + (let ((table (make-hash-table :test #'equal))) + (puthash owner table tp--owner-bindings) + table)))) + +(defun tp--register-binding (binding) + "Register BINDING under its owner and key." + (puthash (tp-binding-key binding) binding + (tp--owner-binding-table (tp-binding-owner binding) t)) + (cl-pushnew binding tp--bindings :test #'eq)) + +(defun tp--unregister-binding (binding) + "Remove BINDING from owner and global registries." + (when-let ((table (tp--owner-binding-table (tp-binding-owner binding)))) + (remhash (tp-binding-key binding) table) + (when (zerop (hash-table-count table)) + (remhash (tp-binding-owner binding) tp--owner-bindings))) + (setq tp--bindings (delq binding tp--bindings))) + +(defun tp--enqueue-binding (binding) + "Add dirty BINDING to the current transaction queue once." + (unless (gethash binding tp--transaction-dirty-set) + (puthash binding t tp--transaction-dirty-set) + (push binding tp--transaction-dirty-queue))) + +(defun tp--mark-binding-dirty (binding) + "Mark live BINDING dirty in the current transaction." + (when (and (tp-binding-p binding) (not (tp-binding-disposed binding))) + (tp--snapshot-binding binding) + (unless (tp-binding-dirty binding) + (setf (tp-binding-dirty binding) t) + (tp--counter-increment :invalidated)) + (tp--enqueue-binding binding))) + +(defun tp--invalidate-subscribers (dependency &optional skip) + "Dirty DEPENDENCY subscribers except SKIP." + (dolist (binding (tp--sorted-subscribers dependency)) + (unless (eq binding skip) + (tp--mark-binding-dirty binding)))) + +(defun tp--record-dependency (dependency) + "Record DEPENDENCY for the binding currently being computed." + (when tp--current-binding + (unless (gethash dependency tp--collected-dependency-set) + (puthash dependency t tp--collected-dependency-set) + (push dependency tp--collected-dependencies)))) + +(defun tp--signal-value-in-transaction (signal) + "Return SIGNAL's candidate or committed value." + (if tp--transaction-active + (let ((candidate (gethash signal tp--transaction-signal-values + tp--reactive-absent))) + (if (eq candidate tp--reactive-absent) + (tp-signal-committed-value signal) + candidate)) + (tp-signal-committed-value signal))) + +(defun tp--validate-live-signal (signal) + "Signal an error unless SIGNAL is live." + (unless (tp-signal-p signal) + (signal 'wrong-type-argument (list 'tp-signal-p signal))) + (when (tp-signal-disposed signal) + (signal 'tp-disposed-signal (list (tp-signal-id signal))))) + +(defun tp-signal-live-p (signal) + "Return non-nil when SIGNAL is a live TP signal." + (and (tp-signal-p signal) (not (tp-signal-disposed signal)))) + +(defun tp--buffer-signal-kill () + "Dispose signals scoped to the buffer being killed." + (let ((signals tp--buffer-signals)) + (setq tp--buffer-signals nil) + (dolist (signal signals) + (tp--dispose-signal signal)))) + +(cl-defun tp-signal-create (initial-value &key (equality #'equal) (scope 'global)) + "Create a signal holding INITIAL-VALUE. +EQUALITY compares writes. SCOPE is `global' or a live buffer." + (unless (functionp equality) + (signal 'wrong-type-argument (list 'functionp equality))) + (unless (or (eq scope 'global) (buffer-live-p scope)) + (signal 'tp-invalid-signal-scope (list scope))) + (let ((signal (tp--make-signal + :id (cl-incf tp--signal-id-counter) + :committed-value initial-value :equality equality + :subscribers (make-hash-table :test #'eq) + :scope scope :revision 0))) + (push signal tp--signals) + (when (bufferp scope) + (with-current-buffer scope + (push signal tp--buffer-signals) + (add-hook 'kill-buffer-hook #'tp--buffer-signal-kill nil t))) + signal)) + +(defun tp-signal-peek (signal) + "Return SIGNAL's effective value without collecting a dependency." + (tp--validate-live-signal signal) + (tp--signal-value-in-transaction signal)) + +(defun tp-signal-read (signal) + "Read SIGNAL and register the current binding as a subscriber." + (tp--validate-live-signal signal) + (tp--record-dependency signal) + (tp--signal-value-in-transaction signal)) + +(defun tp--set-signal-candidate (signal value) + "Set candidate VALUE for SIGNAL in the active transaction." + (let ((old (tp--signal-value-in-transaction signal))) + (unless (funcall (tp-signal-equality signal) old value) + (when (eq (gethash signal tp--transaction-signal-values + tp--reactive-absent) + tp--reactive-absent) + (push signal tp--transaction-signals)) + (puthash signal value tp--transaction-signal-values) + (tp--invalidate-subscribers signal) + (when (and tp--current-binding + (gethash signal tp--collected-dependency-set)) + (tp--mark-binding-dirty tp--current-binding)))) + value) + +(defun tp-signal-set (signal value) + "Set SIGNAL to VALUE transactionally and return VALUE." + (tp--validate-live-signal signal) + (if tp--transaction-active + (tp--set-signal-candidate signal value) + (tp--call-with-transaction + (lambda () (tp--set-signal-candidate signal value))))) + +(defun tp-signal-subscriber-count (signal) + "Return the number of subscribers attached to SIGNAL. +Disposed signals return zero." + (unless (tp-signal-p signal) + (signal 'wrong-type-argument (list 'tp-signal-p signal))) + (hash-table-count (tp-signal-subscribers signal))) + +(defun tp--binding-dependency-path (start target visited) + "Return a binding path from START to TARGET, avoiding VISITED." + (cond + ((eq start target) (list target)) + ((memq start visited) nil) + (t + (cl-loop for dependency in (tp-binding-dependencies start) + when (tp-binding-p dependency) + for path = (tp--binding-dependency-path + dependency target (cons start visited)) + when path return (cons start path))))) + +(defun tp--stack-cycle-path (target) + "Return the active compute-stack cycle ending at TARGET." + (let ((path (reverse tp--binding-compute-stack))) + (while (and path (not (eq (car path) target))) + (setq path (cdr path))) + (append path (list target)))) + +(defun tp--signal-binding-cycle (path) + "Signal a binding-cycle error for binding PATH." + (signal 'tp-binding-cycle + (list (mapcar (lambda (binding) + (copy-tree (tp-binding-key binding))) + path)))) + +(defun tp--validate-binding-read (binding &optional computing-only) + "Validate reading BINDING from the current computation. +When COMPUTING-ONLY is non-nil, defer old-graph traversal until after a +dirty target has recomputed." + (when tp--current-binding + (when (eq (tp-binding-state binding) 'computing) + (tp--signal-binding-cycle (tp--stack-cycle-path binding))) + (unless computing-only + (when-let ((path (tp--binding-dependency-path + binding tp--current-binding nil))) + (tp--signal-binding-cycle (cons tp--current-binding path)))))) + +(defun tp--replace-binding-dependencies (binding dependencies) + "Replace BINDING dependencies with DEPENDENCIES." + (let ((old (tp-binding-dependencies binding))) + (dolist (dependency old) + (unless (memq dependency dependencies) + (tp--subscription-remove dependency binding))) + (dolist (dependency dependencies) + (unless (memq dependency old) + (tp--subscription-add dependency binding))) + (setf (tp-binding-dependencies binding) dependencies))) + +(defun tp--note-binding-recompute (binding) + "Record one recomputation of BINDING and reject runaway feedback." + (let ((count (1+ (gethash binding tp--transaction-recompute-counts 0)))) + (puthash binding count tp--transaction-recompute-counts) + (when (> count 100) + (tp--signal-binding-cycle (list binding binding))))) + +(defun tp--binding-recompute (binding) + "Recompute dirty BINDING inside the active transaction." + (tp--snapshot-binding binding) + (tp--note-binding-recompute binding) + (let ((requester tp--current-binding) + (old (tp-binding-last-value binding)) + (initialized (tp-binding-initialized-p binding)) + (tp--current-binding binding) + (tp--binding-compute-stack (cons binding tp--binding-compute-stack)) + (tp--collected-dependency-set (make-hash-table :test #'eq)) + (tp--collected-dependencies nil)) + (setf (tp-binding-state binding) 'computing + (tp-binding-dirty binding) nil) + (let* ((value (funcall (tp-binding-compute binding))) + (dependencies (nreverse tp--collected-dependencies)) + (changed (or (not initialized) + (not (funcall (tp-binding-equality binding) + old value))))) + (tp--replace-binding-dependencies binding dependencies) + (setf (tp-binding-state binding) 'clean) + (tp--counter-increment :recomputed) + (if changed + (progn + (setf (tp-binding-last-value binding) value + (tp-binding-initialized-p binding) t + (tp-binding-revision binding) + (1+ (tp-binding-revision binding))) + (tp--invalidate-subscribers binding requester)) + (tp--counter-increment :skipped)) + value))) + +(defun tp--validate-live-binding (binding) + "Signal an error unless BINDING is live." + (unless (tp-binding-p binding) + (signal 'wrong-type-argument (list 'tp-binding-p binding))) + (when (tp-binding-disposed binding) + (signal 'tp-disposed-binding (list (tp-binding-id binding))))) + +(defun tp-binding-live-p (binding) + "Return non-nil when BINDING is a live TP binding." + (and (tp-binding-p binding) (not (tp-binding-disposed binding)))) + +(defun tp-binding-read (binding) + "Read BINDING's memoized value and collect a dependency." + (tp--validate-live-binding binding) + (tp--validate-binding-read binding t) + (when (tp-binding-dirty binding) + (if tp--transaction-active + (tp--binding-recompute binding) + (tp--call-with-transaction (lambda () (tp--binding-recompute binding))))) + (tp--validate-binding-read binding) + (tp--record-dependency binding) + (tp-binding-last-value binding)) + +(defun tp-binding-subscriber-count (binding) + "Return the number of bindings depending directly on BINDING. +Disposed bindings return zero." + (unless (tp-binding-p binding) + (signal 'wrong-type-argument (list 'tp-binding-p binding))) + (hash-table-count (tp-binding-subscribers binding))) + +(defun tp-binding-dependency-count (binding) + "Return BINDING's current direct dependency count. +Disposed bindings return zero." + (unless (tp-binding-p binding) + (signal 'wrong-type-argument (list 'tp-binding-p binding))) + (length (tp-binding-dependencies binding))) + +(defun tp--validate-binding-options (compute equality lifecycle) + "Validate binding COMPUTE, EQUALITY, and LIFECYCLE." + (unless (functionp compute) + (signal 'wrong-type-argument (list 'functionp compute))) + (unless (functionp equality) + (signal 'wrong-type-argument (list 'functionp equality))) + (unless (memq lifecycle '(delete retain)) + (signal 'tp-reactive-error (list :lifecycle lifecycle)))) + +(defun tp--bind-in-transaction (owner key compute equality lifecycle) + "Install COMPUTE for OWNER and KEY in the active transaction. +EQUALITY compares values and LIFECYCLE controls retention." + (let* ((table (tp--owner-binding-table owner t)) + (binding (gethash key table))) + (if binding + (unless (and (eq compute (tp-binding-compute binding)) + (eq equality (tp-binding-equality binding)) + (eq lifecycle (tp-binding-lifecycle binding))) + (tp--snapshot-binding binding) + (setf (tp-binding-compute binding) compute + (tp-binding-equality binding) equality + (tp-binding-lifecycle binding) lifecycle) + (tp--mark-binding-dirty binding)) + (setq binding + (tp--make-binding + :id (cl-incf tp--binding-id-counter) + :owner owner :key (copy-tree key) :compute compute + :equality equality :subscribers (make-hash-table :test #'eq) + :dirty t :state 'clean :revision 0 :lifecycle lifecycle)) + (tp--register-binding binding) + (push binding tp--transaction-created-bindings) + (tp--enqueue-binding binding)) + binding)) + +(cl-defun tp-bind (owner key compute &key (equality #'equal) (lifecycle 'delete)) + "Idempotently install COMPUTE on OWNER under namespaced KEY. +EQUALITY suppresses unchanged downstream updates. LIFECYCLE is `delete' +or `retain'." + (when (null owner) + (signal 'tp-reactive-error (list :owner owner))) + (when (null key) + (signal 'tp-reactive-error (list :binding-key key))) + (tp--validate-binding-options compute equality lifecycle) + (if tp--transaction-active + (tp--bind-in-transaction owner key compute equality lifecycle) + (tp--call-with-transaction + (lambda () (tp--bind-in-transaction + owner key compute equality lifecycle))))) + +(defun tp--detach-binding-dependencies (binding) + "Remove BINDING from all dependency subscriber tables." + (dolist (dependency (tp-binding-dependencies binding)) + (tp--subscription-remove dependency binding)) + (setf (tp-binding-dependencies binding) nil)) + +(defun tp--detach-binding-subscribers (binding) + "Detach every direct subscriber from BINDING." + (dolist (subscriber (tp--sorted-subscribers binding)) + (tp--snapshot-binding subscriber) + (setf (tp-binding-dependencies subscriber) + (delq binding (tp-binding-dependencies subscriber)) + (tp-binding-dirty subscriber) t) + (tp--subscription-remove binding subscriber))) + +(defun tp--dispose-binding (binding) + "Dispose live BINDING without starting a transaction." + (unless (tp-binding-disposed binding) + (tp--snapshot-binding binding) + (tp--detach-binding-dependencies binding) + (tp--detach-binding-subscribers binding) + (tp--unregister-binding binding) + (setf (tp-binding-disposed binding) t + (tp-binding-dirty binding) nil + (tp-binding-state binding) 'disposed))) + +(defun tp-binding-dispose-owner (owner) + "Dispose every binding installed on OWNER and return the count." + (let ((bindings (when-let ((table (tp--owner-binding-table owner))) + (let (items) + (maphash (lambda (_key binding) (push binding items)) table) + (sort items (lambda (left right) + (< (tp-binding-id left) + (tp-binding-id right)))))))) + (if tp--transaction-active + (dolist (binding bindings) (tp--dispose-binding binding)) + (tp--call-with-transaction + (lambda () (dolist (binding bindings) (tp--dispose-binding binding))))) + (length bindings))) + +(defun tp--dequeue-dirty-binding () + "Return and remove the next queued dirty binding." + (let (binding) + (while (and tp--transaction-dirty-queue (null binding)) + (let ((candidate (pop tp--transaction-dirty-queue))) + (when (gethash candidate tp--transaction-dirty-set) + (remhash candidate tp--transaction-dirty-set) + (setq binding candidate)))) + binding)) + +(defun tp--flush-dirty-bindings () + "Recompute the transaction's exact dirty closure." + (let (binding) + (while (setq binding (tp--dequeue-dirty-binding)) + (when (and (tp-binding-live-p binding) (tp-binding-dirty binding)) + (tp--binding-recompute binding))))) + +(defun tp--restore-binding (binding snapshot) + "Restore BINDING fields from SNAPSHOT." + (setf (tp-binding-compute binding) (tp--binding-snapshot-compute snapshot) + (tp-binding-equality binding) (tp--binding-snapshot-equality snapshot) + (tp-binding-last-value binding) (tp--binding-snapshot-last-value snapshot) + (tp-binding-initialized-p binding) + (tp--binding-snapshot-initialized-p snapshot) + (tp-binding-dependencies binding) + (copy-sequence (tp--binding-snapshot-dependencies snapshot)) + (tp-binding-dirty binding) (tp--binding-snapshot-dirty snapshot) + (tp-binding-state binding) (tp--binding-snapshot-state snapshot) + (tp-binding-revision binding) (tp--binding-snapshot-revision snapshot) + (tp-binding-lifecycle binding) (tp--binding-snapshot-lifecycle snapshot) + (tp-binding-disposed binding) (tp--binding-snapshot-disposed snapshot))) + +(defun tp--rollback-bindings () + "Restore all bindings touched by the active transaction." + (let (snapshots) + (maphash (lambda (binding snapshot) + (push (cons binding snapshot) snapshots)) + tp--transaction-binding-snapshots) + (dolist (binding (append tp--transaction-created-bindings + (mapcar #'car snapshots))) + (tp--detach-binding-dependencies binding)) + (dolist (binding tp--transaction-created-bindings) + (tp--unregister-binding binding) + (setf (tp-binding-disposed binding) t + (tp-binding-state binding) 'disposed)) + (dolist (entry snapshots) + (pcase-let ((`(,binding . ,snapshot) entry)) + (tp--restore-binding binding snapshot) + (unless (tp-binding-disposed binding) + (tp--register-binding binding) + (dolist (dependency (tp-binding-dependencies binding)) + (tp--subscription-add dependency binding))))))) + +(defun tp--commit-signal-values () + "Commit candidate signal values after a successful flush." + (dolist (signal (nreverse tp--transaction-signals)) + (setf (tp-signal-committed-value signal) + (gethash signal tp--transaction-signal-values) + (tp-signal-revision signal) (1+ (tp-signal-revision signal))))) + +(defun tp--call-with-transaction (function) + "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))))))) + +;;;###autoload +(defmacro tp-with-transaction (&rest body) + "Evaluate BODY in one atomic signal and binding transaction." + (declare (indent 0) (debug t)) + `(tp--call-with-transaction (lambda () ,@body))) + +(defun tp--variable-signal-key (symbol scope) + "Return the adapter key for SYMBOL and SCOPE." + (cons symbol scope)) + +(defun tp--variable-signal-watcher (symbol new-value operation where) + "Forward SYMBOL's NEW-VALUE write into its exact signal adapter. +OPERATION and WHERE follow the standard variable watcher protocol." + (when (eq operation 'set) + (when-let ((signal (gethash (tp--variable-signal-key + symbol (or where 'global)) + tp--variable-signals))) + (when (tp-signal-live-p signal) + (tp-signal-set signal new-value))))) + +(defun tp--variable-signal-initial-value (symbol scope) + "Return SYMBOL's initial adapter value in SCOPE." + (if (eq scope 'global) + (if (default-boundp symbol) (default-value symbol) nil) + (with-current-buffer scope + (if (boundp symbol) (symbol-value symbol) nil)))) + +(defun tp-variable-signal (symbol &optional buffer) + "Return the signal adapting SYMBOL globally or in BUFFER." + (unless (symbolp symbol) + (signal 'wrong-type-argument (list 'symbolp symbol))) + (when (and buffer (not (buffer-live-p buffer))) + (signal 'tp-invalid-signal-scope (list buffer))) + (let* ((scope (or buffer 'global)) + (key (tp--variable-signal-key symbol scope))) + (or (gethash key tp--variable-signals) + (let ((signal (tp-signal-create + (tp--variable-signal-initial-value symbol scope) + :scope scope))) + (setf (tp-signal-adapter-key signal) key) + (puthash key signal tp--variable-signals) + (unless (memq symbol tp--variable-signal-watched) + (add-variable-watcher symbol #'tp--variable-signal-watcher) + (push symbol tp--variable-signal-watched)) + signal)))) + +(defun tp--symbol-has-variable-signal-p (symbol) + "Return non-nil when SYMBOL still owns an adapter signal." + (let ((found nil)) + (maphash (lambda (key _signal) + (when (eq (car key) symbol) (setq found t))) + tp--variable-signals) + found)) + +(defun tp--dispose-signal (signal) + "Dispose SIGNAL and detach its graph and adapter state." + (unless (tp-signal-disposed signal) + (dolist (binding (tp--sorted-subscribers signal)) + (setf (tp-binding-dependencies binding) + (delq signal (tp-binding-dependencies binding)) + (tp-binding-dirty binding) t)) + (clrhash (tp-signal-subscribers signal)) + (when-let ((key (tp-signal-adapter-key signal))) + (remhash key tp--variable-signals) + (let ((symbol (car key))) + (unless (tp--symbol-has-variable-signal-p symbol) + (remove-variable-watcher symbol #'tp--variable-signal-watcher) + (setq tp--variable-signal-watched + (delq symbol tp--variable-signal-watched))))) + (when-let ((scope (and (bufferp (tp-signal-scope signal)) + (tp-signal-scope signal)))) + (when (buffer-live-p scope) + (with-current-buffer scope + (setq tp--buffer-signals (delq signal tp--buffer-signals))))) + (setf (tp-signal-disposed signal) t) + (setq tp--signals (delq signal tp--signals)))) + +(defun tp-signal-dispose (signal) + "Dispose SIGNAL, detach its subscriptions, and return nil." + (unless (tp-signal-p signal) + (signal 'wrong-type-argument (list 'tp-signal-p signal))) + (when tp--transaction-active + (signal 'tp-reactive-error (list :dispose-during-transaction))) + (tp--dispose-signal signal) + nil) + +(defun tp--reactive-graph-reset () + "Clear all signal, binding, adapter, and scheduler state." + (when tp--transaction-active + (signal 'tp-reactive-error (list :reset-during-transaction))) + (dolist (binding tp--bindings) + (tp--detach-binding-dependencies binding) + (clrhash (tp-binding-subscribers binding)) + (setf (tp-binding-disposed binding) t + (tp-binding-state binding) 'disposed)) + (dolist (signal (copy-sequence tp--signals)) + (tp--dispose-signal signal)) + (clrhash tp--owner-bindings) + (clrhash tp--variable-signals) + (setq tp--bindings nil + tp--signals nil + tp--variable-signal-watched nil) + (tp-reactive-reset-counters)) + (defvar tp-reactive-deps nil "Alist mapping reactive variables to dependent layers. Each element: (VAR-SYMBOL . ((LAYER-NAME . REACTIVE-PROPS) ...)).") @@ -436,6 +1093,7 @@ it to allow re-definition to change initial values." (defun tp-reactive-reset () "Reset all reactive text property watchers and dependencies." (interactive) + (tp--reactive-graph-reset) ;; Remove all variable watchers (dolist (dep tp-reactive-deps) (let ((var-sym (car dep))) diff --git a/tp.el b/tp.el index a44fd79..1826a86 100644 --- a/tp.el +++ b/tp.el @@ -24,8 +24,8 @@ ;; debug logging, pure $var utilities. ;; tp-style.el Property schemas, structured selectors, cascade, ;; custom properties, and explicit computed values. -;; tp-reactive.el Reactive state: dependency registry, variable -;; watchers, batching queue. +;; tp-reactive.el Exact signals, bindings, transactions, scoped variable +;; adapters, plus temporary legacy layer watcher state. ;; 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',