From 905d3523ac665950de5588444b950239a5ae1156 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Thu, 6 Aug 2026 03:31:50 +0800 Subject: [PATCH] feat(tp): add unified convenience APIs Expose one-shot string and buffer styling plus reactive range watches over the same schema, mutation, binding, and retained surface core. Verification: 712 ERT tests; 92 doctests; shuffled seed 20260806; compile-all WERROR=t. --- CHANGELOG.md | 1 + README.md | 10 ++++ README_CN.md | 10 ++++ docs/API-SEMANTICS.md | 6 +++ docs/ARCHITECTURE.md | 2 + tests/tp-convenience-tests.el | 95 +++++++++++++++++++++++++++++++++++ tp-core.el | 21 ++++++++ tp-ops.el | 26 ++++++++++ tp-style.el | 8 +++ tp-surface.el | 43 +++++++++++----- 10 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 tests/tp-convenience-tests.el diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc203f..70d0800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the tp library are documented here. ### Added +- `tp-propertize`, `tp-apply`, and `tp-watch` now provide simple one-shot string, one-shot buffer-range, and reactive existing-text entry points over the same schema/cascade/projector and retained properties-surface core. - 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. diff --git a/README.md b/README.md index 70ba332..f99d4a4 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ The TP 1.0 migration has begun with a pure schema-driven cascade kernel. `tp-sty `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. +Ordinary callers can use the same core without manually constructing runtime objects: `tp-propertize` returns a styled string copy, `tp-apply` applies declarations once to an existing buffer range, and `tp-watch` keeps an existing range's properties synchronized with signals. These calls accept native Emacs property declarations; callbacks such as `help-echo` remain literal values, while only explicitly wrapped `tp-computed` sources are evaluated by the cascade. + ### Core Innovations 1. **Unified API Parameter Conventions**: All functions support multiple flexible calling patterns, working seamlessly with both strings and buffers @@ -544,6 +546,14 @@ A complete overview of all tp.el functions organized by category: | `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 | +#### Convenience APIs + +| Function | Description | +|----------|-------------| +| `tp-propertize` | Return a styled string copy using the schema/cascade/projector core | +| `tp-apply` | Apply native declarations once to an existing buffer range | +| `tp-watch` | Reactively maintain declarations on an existing range through a properties surface | + #### Property Layer Definition Functions | Function | Description | |----------|-------------| diff --git a/README_CN.md b/README_CN.md index 5186385..f55635f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -179,6 +179,8 @@ TP 1.0 迁移已经从纯 schema-driven cascade kernel 开始。`tp-style.el` `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 会被拒绝,避免所有权静默分裂。 +普通调用者不必手工构造 runtime object,也能使用同一个 core:`tp-propertize` 返回带样式的字符串副本,`tp-apply` 把声明一次性应用到已有 buffer range,`tp-watch` 则让已有 range 的属性随 signal 保持同步。这些入口接受 Emacs 原生 property declarations;`help-echo` 等 callback 保持 literal value,只有显式包装的 `tp-computed` source 才由 cascade 求值。 + ### 核心创新 1. **统一的 API 参数规范**:所有函数支持多种灵活的调用方式,同时适用于字符串和缓冲区 @@ -539,6 +541,14 @@ tp.el 所有函数按类别组织的完整概览: | `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 | +#### 便利 API + +| 函数 | 描述 | +|------|------| +| `tp-propertize` | 通过 schema/cascade/projector core 返回带样式的字符串副本 | +| `tp-apply` | 把原生 declarations 一次性应用到已有 buffer range | +| `tp-watch` | 通过 properties surface 响应式维护已有 range 上的 declarations | + #### 属性层定义函数 | 函数 | 描述 | |------|------| diff --git a/docs/API-SEMANTICS.md b/docs/API-SEMANTICS.md index d3d2dc5..5e1bd64 100644 --- a/docs/API-SEMANTICS.md +++ b/docs/API-SEMANTICS.md @@ -202,3 +202,9 @@ tp 不会把外部属性收编成新的匿名层,也不会静默覆盖它。 - 同一 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 rollback,TP 的精确 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 会开启新 transaction;observer error 只写入 report,不回滚。 + +## 15. 简单与响应式便利入口 + +- `tp-propertize STRING DECLARATIONS` 接受 Emacs 原生 property plist,把它转换为 canonical `text/` declarations,经 schema/cascade/projector 后应用到 STRING 的防御性副本;它不建立 object、binding、anchor 或 surface。 +- `tp-apply BUFFER START END DECLARATIONS` 使用同一 projection 与现有 range mutation primitive,只修改声明过的 direct properties,保留文本和未声明 property,并返回 `(START . END)`。 +- `tp-watch BUFFER START END COMPUTE` 创建一个 properties-only surface。COMPUTE 是返回原生 property plist 的零参数函数;其 signal/binding dependencies 由 exact graph 收集。返回值就是可传给 `tp-surface-inspect`、`tp-surface-report` 和 `tp-surface-unmount` 的 opaque surface。首次 publication 失败会释放 convenience 层创建的 anchor。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce8b73e..fa19f39 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -187,6 +187,8 @@ Stage 2 canonical façade 是内部模型,不改变公开入口和历史返回 `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。 +`tp-propertize` 与 `tp-apply` 是 `tp-style` projection 加 `tp-ops` mutation primitive 的 one-shot 组合,不创建 runtime state。`tp-watch` 只把 range anchor、object binding 和 properties surface 组合成普通用户入口;它没有独立 scheduler、diff 或 publication path。 + #### 依赖注册与管理 | 函数/变量 | 描述 | |------|------| diff --git a/tests/tp-convenience-tests.el b/tests/tp-convenience-tests.el new file mode 100644 index 0000000..02d1c81 --- /dev/null +++ b/tests/tp-convenience-tests.el @@ -0,0 +1,95 @@ +;;; tp-convenience-tests.el --- Tests for TP convenience APIs -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Geekinney + +;;; Commentary: + +;; Contract tests for one-shot and reactive text-property conveniences. + +;;; Code: + +(require 'ert) +(require 'tp) + +(ert-deftest tp-convenience-test-propertize-uses-style-projection () + "Propertizing projects native declarations without mutating the input." + (let* ((callback (lambda (_window _object _position) "help")) + (source "Hello") + (result + (tp-propertize + source + (list 'face '(:foreground "red" :weight bold) + 'help-echo callback 'keymap nil)))) + (should (equal source "Hello")) + (should-not (text-properties-at 0 source)) + (should (equal (get-text-property 0 'face result) + '(:foreground "red" :weight bold))) + (should (eq (get-text-property 0 'help-echo result) callback)) + (should (plist-member (text-properties-at 0 result) 'keymap)) + (should-not (get-text-property 0 'keymap result)))) + +(ert-deftest tp-convenience-test-apply-mutates-only-the-requested-range () + "Applying declarations preserves text and properties outside the range." + (with-temp-buffer + (insert "abcdef") + (put-text-property 1 7 'category 'host) + (should (equal (tp-apply (current-buffer) 2 5 '(face italic)) '(2 . 5))) + (should (equal (buffer-string) "abcdef")) + (should-not (get-text-property 1 'face)) + (should (eq (get-text-property 2 'face) 'italic)) + (should-not (get-text-property 5 'face)) + (should (eq (get-text-property 3 'category) 'host)))) + +(ert-deftest tp-convenience-test-apply-rejects-invalid-targets-and-ranges () + "Applying never falls back from an invalid target to the current buffer." + (with-temp-buffer + (insert "safe") + (should-error (tp-apply " *missing-tp-buffer*" 1 2 '(face bold)) + :type 'tp-unsupported-buffer) + (should-error (tp-apply (current-buffer) 4 2 '(face bold)) + :type 'args-out-of-range) + (should (equal (buffer-string) "safe")) + (should-not (text-properties-at 0 (buffer-string))))) + +(ert-deftest tp-convenience-test-watch-reacts-and-restores-host-properties () + "Watching a range updates properties through one retained surface." + (with-temp-buffer + (insert "Status") + (put-text-property 1 7 'help-echo "host") + (let* ((connected (tp-signal-create nil)) + (surface + (tp-watch + (current-buffer) 1 7 + (lambda () + (if (tp-signal-read connected) + '(face (:foreground "green") help-echo "Connected") + '(face (:foreground "red") help-echo "Disconnected")))))) + (should (equal (buffer-string) "Status")) + (should (equal (get-text-property 2 'face) + '(:foreground "red"))) + (should (equal (get-text-property 2 'help-echo) "Disconnected")) + (tp-signal-set connected t) + (should (equal (get-text-property 2 'face) + '(:foreground "green"))) + (should (equal (get-text-property 2 'help-echo) "Connected")) + (should (= (tp-surface-revision surface) 2)) + (tp-surface-unmount surface) + (should-not (get-text-property 2 'face)) + (should (equal (get-text-property 2 'help-echo) "host"))))) + +(ert-deftest tp-convenience-test-failed-watch-releases-its-range-anchor () + "A failed first watch publication leaves no live marker-backed anchor." + (with-temp-buffer + (insert "host") + (let ((original (symbol-function 'tp-range-anchor-create)) + anchor) + (cl-letf (((symbol-function 'tp-range-anchor-create) + (lambda (&rest arguments) + (setq anchor (apply original arguments))))) + (should-error + (tp-watch (current-buffer) 1 5 + (lambda () (error "Initial compute failed"))))) + (should-not (tp-range-anchor-live-p anchor))))) + +(provide 'tp-convenience-tests) +;;; tp-convenience-tests.el ends here diff --git a/tp-core.el b/tp-core.el index 7052ac4..7292613 100644 --- a/tp-core.el +++ b/tp-core.el @@ -30,6 +30,8 @@ :prefix "tp-" :group 'development) +(define-error 'tp-unsupported-buffer "Unsupported TP buffer") + (defcustom tp-debug-mode nil "When non-nil, enable debug logging for reactive updates. Debug messages are logged to the *tp-debug* buffer and optionally @@ -161,6 +163,25 @@ FORMAT-STRING and ARGS are passed to `format'." (max (or start (car bounds)) (car bounds)) (min (or end (cdr bounds)) (cdr bounds))))) +(defun tp--buffer-position-p (position buffer) + "Return non-nil when POSITION belongs to BUFFER or is an integer." + (or (integerp position) + (and (markerp position) + (eq (marker-buffer position) buffer) + (marker-position position)))) + +(defun tp--validate-buffer-range (buffer start end) + "Validate START and END as an ordered live range in BUFFER." + (unless (buffer-live-p buffer) + (signal 'tp-unsupported-buffer (list buffer))) + (with-current-buffer buffer + (save-restriction + (widen) + (unless (and (tp--buffer-position-p start buffer) + (tp--buffer-position-p end buffer) + (<= (point-min) start) (<= start end) (<= end (point-max))) + (signal 'args-out-of-range (list buffer start end)))))) + (defun tp--presence-at (pos property &optional object) "Return canonical presence of PROPERTY at POS in OBJECT." (let ((cell (plist-member (text-properties-at pos object) property))) diff --git a/tp-ops.el b/tp-ops.el index b56540f..ab1d41c 100644 --- a/tp-ops.el +++ b/tp-ops.el @@ -24,6 +24,7 @@ (require 'cl-lib) (require 'dash) (require 'tp-core) +(require 'tp-style) (require 'tp-reactive) (require 'tp-layer) @@ -437,6 +438,31 @@ Returns a new propertized string." do (put-text-property start end key val result)) result)))))) +;;;###autoload +(defun tp-propertize (string declarations) + "Return a copy of STRING styled by native DECLARATIONS. +DECLARATIONS pass through TP's property schemas, cascade, and projector. +Ordinary function values remain literal text-property values." + (unless (stringp string) + (signal 'wrong-type-argument (list 'stringp string))) + (tp--apply-props-to-string + string 0 (length string) + (tp--project-text-declarations declarations))) + +;;;###autoload +(defun tp-apply (buffer start end declarations) + "Apply native DECLARATIONS once to BUFFER from START to END. +The operation preserves text and direct properties not named by DECLARATIONS. +Return the committed range as a START . END cons." + (let* ((target (get-buffer buffer)) + (_range (tp--validate-buffer-range target start end)) + (range (tp--native-range-from-object target start end)) + (properties (tp--project-text-declarations declarations))) + (tp--apply-props-by-operation + (tp--native-range-start range) (tp--native-range-end range) + properties target nil) + (cons (tp--native-range-start range) (tp--native-range-end range)))) + (defun tp-set (start-or-string &optional end-or-prop props-or-val &rest rest) "Set text properties on string or buffer region. diff --git a/tp-style.el b/tp-style.el index ec051d4..b2db707 100644 --- a/tp-style.el +++ b/tp-style.el @@ -912,6 +912,14 @@ or values plist. When PROVENANCE is non-nil, winner metadata is retained." (setq result (tp--deep-merge-plist result projected))))) result)) +(defun tp--project-text-declarations (declarations) + "Project native text property DECLARATIONS through the style core." + (tp-project-style + (tp-compute-style + (tp-subject-create :type 'text) + :declarations (tp-text-declarations declarations) + :rules nil))) + (tp--register-default-text-properties) (provide 'tp-style) diff --git a/tp-surface.el b/tp-surface.el index 0c06f39..432c978 100644 --- a/tp-surface.el +++ b/tp-surface.el @@ -42,8 +42,6 @@ (define-error 'tp-dead-surface "Dead TP surface" 'tp-surface-error) (define-error 'tp-invalid-range-anchor "Invalid TP range anchor" 'tp-surface-error) -(define-error 'tp-unsupported-buffer "Unsupported TP surface buffer" - 'tp-surface-error) (cl-defstruct (tp-surface-plan (:constructor tp--make-surface-plan)) "Pure retained node data accepted by a TP surface." @@ -579,17 +577,6 @@ OPTIONS configure the mount and INITIAL is non-nil for first publication." result) (unless success (tp--discard-context context))))) -(defun tp--validate-buffer-range (buffer start end) - "Validate START and END as an ordered live range in BUFFER." - (unless (buffer-live-p buffer) - (signal 'tp-unsupported-buffer (list buffer))) - (with-current-buffer buffer - (save-restriction - (widen) - (unless (and (integer-or-marker-p start) (integer-or-marker-p end) - (<= (point-min) start) (<= start end) (<= end (point-max))) - (signal 'args-out-of-range (list buffer start end)))))) - (cl-defun tp-range-anchor-create (buffer start end &key (start-insertion-type nil) (end-insertion-type t) (boundary-policy 'stale)) @@ -1927,6 +1914,36 @@ owns the property; conflicting host values are preserved and reported." :client-state (tp--surface-client-state surface) :report (tp-surface-report surface))) +(defun tp--watch-producer (anchor compute) + "Return a properties producer backed by ANCHOR and COMPUTE." + (lambda (context) + (let* ((object (tp-object-ensure + context nil 'watch 'text-properties)) + (binding + (tp-bind object '(tp/watch . declarations) + (lambda () + (tp--project-text-declarations (funcall compute)))))) + (tp-object-attach-range context object anchor) + (tp-surface-plan-create + :key 'watch :kind 'text-properties + :props (tp-binding-read binding) :capability 'properties)))) + +;;;###autoload +(defun tp-watch (buffer start end compute) + "Reactively apply declarations from COMPUTE to BUFFER from START to END. +COMPUTE is a zero-argument function returning native text declarations. +Return the underlying properties-only surface for inspection or unmounting." + (unless (functionp compute) + (signal 'wrong-type-argument (list 'functionp compute))) + (let ((anchor (tp-range-anchor-create buffer start end)) surface) + (unwind-protect + (setq surface + (tp-surface-mount + buffer (tp--watch-producer anchor compute) + '(:capability properties :inhibit-read-only t))) + (unless surface (tp--dispose-anchor anchor))) + surface)) + (add-hook 'tp--binding-changed-functions #'tp--surface-binding-changed) (add-hook 'tp--transaction-publish-functions #'tp--surface-publish-transaction) (add-hook 'tp--transaction-rollback-functions #'tp--surface-rollback-transaction)