From 6e52ad51263f93f1b2828d52d3906972d47a4b57 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 23:57:34 +0000 Subject: [PATCH] content: ordered block document + edit ops + 40 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/conformance.sh | 3 +- lib/content/doc.sx | 194 ++++++++++++++++++++++++++++++++++++ lib/content/scoreboard.json | 7 +- lib/content/scoreboard.md | 3 +- lib/content/tests/doc.sx | 132 ++++++++++++++++++++++++ plans/content-on-sx.md | 10 +- 6 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 lib/content/doc.sx create mode 100644 lib/content/tests/doc.sx diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 87c5a412..84d92a01 100755 --- a/lib/content/conformance.sh +++ b/lib/content/conformance.sh @@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then fi fi -SUITES=(block) +SUITES=(block doc) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -34,6 +34,7 @@ run_suite() { (load "lib/guest/reflective/env.sx") (load "lib/smalltalk/eval.sx") (load "lib/content/block.sx") +(load "lib/content/doc.sx") (epoch 2) (eval "(define content-test-pass 0)") (eval "(define content-test-fail 0)") diff --git a/lib/content/doc.sx b/lib/content/doc.sx new file mode 100644 index 00000000..90e9bede --- /dev/null +++ b/lib/content/doc.sx @@ -0,0 +1,194 @@ +;; content-on-sx — ordered block document on Smalltalk-on-SX. +;; +;; A document (CtDoc) is a Smalltalk object holding an ordered sequence of block +;; objects. Editing is a stream of ops (data dicts); doc-apply interprets one op +;; and returns a NEW document — the input is never mutated, so any version is the +;; head of an op stream (replay-friendly for persist + CRDT merge). +;; +;; Op shapes (data, not objects — they are the persist event payload): +;; {:op "insert" :block :after } ; after nil = prepend +;; {:op "update" :id :field :value } +;; {:op "move" :id :index } +;; {:op "delete" :id } + +(define + content-bootstrap-doc! + (fn + () + (begin + (st-class-define! "CtDoc" "Object" (list "id" "blocks")) + (ct-def-method! "CtDoc" "id" "id ^ id") + (ct-def-method! "CtDoc" "blocks" "blocks ^ blocks") + (ct-def-method! "CtDoc" "type" "type ^ #document") + true))) + +;; ── construction ── +(define + doc-new + (fn + (id blocks) + (st-iv-set! + (st-iv-set! (st-make-instance "CtDoc") "id" id) + "blocks" + blocks))) + +(define doc-empty (fn (id) (doc-new id (list)))) + +;; ── accessors (message dispatch) ── +(define doc-id (fn (doc) (st-send doc "id" (list)))) +(define doc-type (fn (doc) (str (st-send doc "type" (list))))) +(define doc-blocks (fn (doc) (st-send doc "blocks" (list)))) +(define doc-count (fn (doc) (len (doc-blocks doc)))) +(define doc-block-at (fn (doc i) (nth (doc-blocks doc) i))) + +(define doc? (fn (v) (and (st-instance? v) (= (get v :class) "CtDoc")))) + +;; ── list helpers over block sequences ── +(define + ct-index-loop + (fn + (blocks id i) + (cond + ((= (len blocks) 0) -1) + ((= (blk-id (first blocks)) id) i) + (else (ct-index-loop (rest blocks) id (+ i 1)))))) + +(define ct-index-of (fn (blocks id) (ct-index-loop blocks id 0))) + +(define + ct-insert-at + (fn + (blocks i x) + (cond + ((= i 0) (cons x blocks)) + ((= (len blocks) 0) (list x)) + (else + (cons + (first blocks) + (ct-insert-at (rest blocks) (- i 1) x)))))) + +(define + ct-remove-id + (fn + (blocks id) + (filter (fn (b) (if (= (blk-id b) id) false true)) blocks))) + +(define + ct-replace-id + (fn + (blocks id f) + (map (fn (b) (if (= (blk-id b) id) (f b) b)) blocks))) + +;; ── query ── +(define doc-index-of (fn (doc id) (ct-index-of (doc-blocks doc) id))) + +(define + doc-find + (fn + (doc id) + (let + ((hits (filter (fn (b) (= (blk-id b) id)) (doc-blocks doc)))) + (if (= (len hits) 0) nil (first hits))))) + +(define + doc-has? + (fn (doc id) (if (= (doc-index-of doc id) -1) false true))) + +;; ── structural edits (each returns a new document) ── +(define doc-with-blocks (fn (doc blocks) (st-iv-set! doc "blocks" blocks))) + +(define + doc-append + (fn + (doc block) + (doc-with-blocks doc (append (doc-blocks doc) (list block))))) + +(define + doc-insert-at + (fn + (doc block i) + (doc-with-blocks doc (ct-insert-at (doc-blocks doc) i block)))) + +(define + doc-insert-after + (fn + (doc block after-id) + (let + ((blocks (doc-blocks doc))) + (if + (= after-id nil) + (doc-with-blocks doc (cons block blocks)) + (let + ((idx (ct-index-of blocks after-id))) + (if + (= idx -1) + (doc-with-blocks doc (append blocks (list block))) + (doc-with-blocks + doc + (ct-insert-at blocks (+ idx 1) block)))))))) + +(define + doc-update + (fn + (doc id field value) + (doc-with-blocks + doc + (ct-replace-id (doc-blocks doc) id (fn (b) (blk-set b field value)))))) + +(define + doc-delete + (fn (doc id) (doc-with-blocks doc (ct-remove-id (doc-blocks doc) id)))) + +(define + doc-move + (fn + (doc id i) + (let + ((blk (doc-find doc id))) + (if + (= blk nil) + doc + (doc-with-blocks + doc + (ct-insert-at (ct-remove-id (doc-blocks doc) id) i blk)))))) + +;; ── op constructors (data payload, reused by persist op log) ── +(define op-insert (fn (block after) {:after after :op "insert" :block block})) + +(define op-update (fn (id field value) {:field field :id id :op "update" :value value})) + +(define op-move (fn (id index) {:id id :op "move" :index index})) + +(define op-delete (fn (id) {:id id :op "delete"})) + +;; ── op interpreter ── +(define + doc-apply + (fn + (doc op) + (let + ((kind (get op :op))) + (cond + ((= kind "insert") + (doc-insert-after doc (get op :block) (get op :after))) + ((= kind "update") + (doc-update doc (get op :id) (get op :field) (get op :value))) + ((= kind "move") (doc-move doc (get op :id) (get op :index))) + ((= kind "delete") (doc-delete doc (get op :id))) + (else (error (str "unknown op: " kind))))))) + +(define + doc-apply-all + (fn + (doc ops) + (if + (= (len ops) 0) + doc + (doc-apply-all (doc-apply doc (first ops)) (rest ops))))) + +;; ── render-agnostic snapshot: list of (id . type) for assertions/debug ── +(define doc-ids (fn (doc) (map (fn (b) (blk-id b)) (doc-blocks doc)))) + +(define + doc-types + (fn (doc) (map (fn (b) (blk-type b)) (doc-blocks doc)))) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 6ebb3777..ae8afa03 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -1,8 +1,9 @@ { "suites": { - "block": {"pass": 38, "fail": 0} + "block": {"pass": 38, "fail": 0}, + "doc": {"pass": 40, "fail": 0} }, - "total_pass": 38, + "total_pass": 78, "total_fail": 0, - "total": 38 + "total": 78 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 13b4bd16..3912f6bf 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -5,4 +5,5 @@ _Generated by `lib/content/conformance.sh`_ | Suite | Pass | Fail | Total | |-------|-----:|-----:|------:| | block | 38 | 0 | 38 | -| **Total** | **38** | **0** | **38** | +| doc | 40 | 0 | 40 | +| **Total** | **78** | **0** | **78** | diff --git a/lib/content/tests/doc.sx b/lib/content/tests/doc.sx new file mode 100644 index 00000000..12681403 --- /dev/null +++ b/lib/content/tests/doc.sx @@ -0,0 +1,132 @@ +;; Phase 1 — ordered block document: apply edit ops, structural moves. +;; Every op returns a NEW document; the input is never mutated. + +(st-bootstrap-classes!) +(content-bootstrap-blocks!) +(content-bootstrap-doc!) + +(define h (mk-heading "h" 1 "Title")) +(define p1 (mk-text "p1" "First")) +(define p2 (mk-text "p2" "Second")) +(define img (mk-image "img" "/c.png" "cat")) + +;; ── empty + construction ── +(define d0 (doc-empty "doc1")) +(content-test "empty id" (doc-id d0) "doc1") +(content-test "empty type" (doc-type d0) "document") +(content-test "empty count" (doc-count d0) 0) +(content-test "doc? on doc" (doc? d0) true) +(content-test "doc? on block" (doc? h) false) + +;; ── append + order ── +(define d1 (doc-append (doc-append (doc-append d0 h) p1) p2)) +(content-test "append count" (doc-count d1) 3) +(content-test "append order" (doc-ids d1) (list "h" "p1" "p2")) +(content-test "append types" (doc-types d1) (list "heading" "text" "text")) +(content-test "block-at 0" (blk-id (doc-block-at d1 0)) "h") + +;; ── append is immutable ── +(content-test "append leaves original" (doc-count d0) 0) + +;; ── find / index / has ── +(content-test "find p1" (blk-id (doc-find d1 "p1")) "p1") +(content-test "find missing" (doc-find d1 "nope") nil) +(content-test "index-of p2" (doc-index-of d1 "p2") 2) +(content-test "index-of missing" (doc-index-of d1 "nope") -1) +(content-test "has? yes" (doc-has? d1 "h") true) +(content-test "has? no" (doc-has? d1 "x") false) + +;; ── insert-after ── +(define d2 (doc-insert-after d1 img "h")) +(content-test "insert-after order" (doc-ids d2) (list "h" "img" "p1" "p2")) +(content-test + "insert-after prepend" + (doc-ids (doc-insert-after d1 img nil)) + (list "img" "h" "p1" "p2")) +(content-test + "insert-after missing appends" + (doc-ids (doc-insert-after d1 img "zzz")) + (list "h" "p1" "p2" "img")) +(content-test "insert-after immutable" (doc-ids d1) (list "h" "p1" "p2")) + +;; ── insert-at ── +(content-test + "insert-at 0" + (doc-ids (doc-insert-at d1 img 0)) + (list "img" "h" "p1" "p2")) +(content-test + "insert-at 1" + (doc-ids (doc-insert-at d1 img 1)) + (list "h" "img" "p1" "p2")) + +;; ── update (copy-on-write block) ── +(define d3 (doc-update d1 "p1" "text" "Edited")) +(content-test + "update value" + (str (blk-send (doc-find d3 "p1") "text")) + "Edited") +(content-test "update keeps order" (doc-ids d3) (list "h" "p1" "p2")) +(content-test + "update immutable" + (str (blk-send (doc-find d1 "p1") "text")) + "First") + +;; ── delete ── +(define d4 (doc-delete d1 "p1")) +(content-test "delete order" (doc-ids d4) (list "h" "p2")) +(content-test "delete count" (doc-count d4) 2) +(content-test "delete immutable" (doc-count d1) 3) +(content-test + "delete missing no-op" + (doc-ids (doc-delete d1 "x")) + (list "h" "p1" "p2")) + +;; ── move ── +(content-test + "move p2 to front" + (doc-ids (doc-move d1 "p2" 0)) + (list "p2" "h" "p1")) +(content-test + "move h to end" + (doc-ids (doc-move d1 "h" 2)) + (list "p1" "p2" "h")) +(content-test + "move missing no-op" + (doc-ids (doc-move d1 "x" 0)) + (list "h" "p1" "p2")) +(content-test "move immutable" (doc-ids d1) (list "h" "p1" "p2")) + +;; ── op constructors + interpreter ── +(content-test + "op-insert apply" + (doc-ids (doc-apply d1 (op-insert img "h"))) + (list "h" "img" "p1" "p2")) +(content-test + "op-delete apply" + (doc-ids (doc-apply d1 (op-delete "h"))) + (list "p1" "p2")) +(content-test + "op-move apply" + (doc-ids (doc-apply d1 (op-move "p2" 0))) + (list "p2" "h" "p1")) +(content-test + "op-update apply" + (str + (blk-send + (doc-find (doc-apply d1 (op-update "p1" "text" "X")) "p1") + "text")) + "X") + +;; ── apply-all: a stream of ops ── +(define + ops + (list (op-insert img "h") (op-delete "p1") (op-move "p2" 0))) +(content-test + "apply-all" + (doc-ids (doc-apply-all d1 ops)) + (list "p2" "h" "img")) +(content-test "apply-all immutable" (doc-ids d1) (list "h" "p1" "p2")) +(content-test + "apply-all empty" + (doc-ids (doc-apply-all d1 (list))) + (list "h" "p1" "p2")) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index eab4a1f5..9bf7ebbe 100644 --- a/plans/content-on-sx.md +++ b/plans/content-on-sx.md @@ -19,7 +19,7 @@ injected adapter, not core. ## Status (rolling) -`bash lib/content/conformance.sh` → **38/38** (Phase 1: typed blocks) +`bash lib/content/conformance.sh` → **78/78** (Phase 1: blocks + doc) ## Ground rules @@ -58,7 +58,7 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Phase 1 — Block document model - [x] `block.sx` — typed block objects -- [ ] `doc.sx` — ordered tree, apply edit op, structural moves +- [x] `doc.sx` — ordered tree, apply edit op, structural moves - [ ] `render.sx` — block tree → HTML/SX - [ ] `api.sx` + tests + scoreboard + conformance.sh @@ -77,6 +77,12 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-06 — Phase 1 `doc.sx`: ordered block document (`CtDoc`) as a + Smalltalk object holding an ordered block sequence. Edit ops are data dicts + (`insert`/`update`/`move`/`delete`) with `op-*` constructors; `doc-apply` / + `doc-apply-all` interpret an op stream, each returning a NEW document (input + never mutated → replay-safe). Structural moves, insert-after/at, find/index, + immutability all tested. 40 tests; suite 78/78. - 2026-06-06 — Phase 1 `block.sx`: typed block objects as Smalltalk instances (`CtBlock` hierarchy: text/heading/code/quote/image/embed/divider/list). Type tag + accessors are message sends (polymorphic dispatch); fields are