Decouple core evaluator from web platform, extract libraries

The core evaluator (spec/evaluator.sx) is now the irreducible computational
core with zero web, rendering, or type-system knowledge. 2531 → 2313 lines.

- Add extensible special form registry (*custom-special-forms* + register-special-form!)
- Add render dispatch hooks (*render-check* / *render-fn*) replacing hardcoded render-active?/is-render-expr?/render-expr
- Extract freeze scopes → spec/freeze.sx (library, not core)
- Extract content addressing → spec/content.sx (library, not core)
- Move sf-deftype/sf-defeffect → spec/types.sx (self-registering)
- Move sf-defstyle → web/forms.sx (self-registering with all web forms)
- Move web tests (defpage, streaming) → web/tests/test-forms.sx
- Add is-else-clause? helper (replaces 5 inline patterns)
- Make escape-html/escape-attr library functions in render.sx (pure SX, not platform-provided)
- Add foundations plan: Step 3.5 (data representations), Step 3.7 (verified components), OCaml for Step 4d
- Update all three bootstrappers (JS 957/957, Python 744/744, OCaml 952/952)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 08:37:50 +00:00
parent 5ab3ecb7e0
commit 06666ac8c4
21 changed files with 886 additions and 603 deletions

48
spec/content.sx Normal file
View File

@@ -0,0 +1,48 @@
;; ==========================================================================
;; content.sx — Content-addressed computation
;;
;; Hash frozen SX to a content identifier. Store and retrieve by CID.
;; The content IS the address — same SX always produces the same CID.
;;
;; This is a library built on top of freeze.sx. It is NOT part of the
;; core evaluator. Load order: evaluator.sx → freeze.sx → content.sx
;;
;; Uses an in-memory content store. Applications can persist to
;; localStorage or IPFS by providing their own store backend.
;; ==========================================================================
(define content-store (dict))
(define content-hash :effects []
(fn (sx-text)
;; djb2 hash → hex string. Simple, deterministic, fast.
;; Real deployment would use SHA-256 / multihash.
(let ((hash 5381))
(for-each (fn (i)
(set! hash (mod (+ (* hash 33) (char-code-at sx-text i)) 4294967296)))
(range 0 (len sx-text)))
(to-hex hash))))
(define content-put :effects [mutation]
(fn (sx-text)
(let ((cid (content-hash sx-text)))
(dict-set! content-store cid sx-text)
cid)))
(define content-get :effects []
(fn (cid)
(get content-store cid)))
;; Freeze a scope → store → return CID
(define freeze-to-cid :effects [mutation]
(fn (scope-name)
(let ((sx-text (freeze-to-sx scope-name)))
(content-put sx-text))))
;; Thaw from CID → look up → restore
(define thaw-from-cid :effects [mutation]
(fn (cid)
(let ((sx-text (content-get cid)))
(when sx-text
(thaw-from-sx sx-text)
true))))

View File

@@ -306,6 +306,26 @@
(scan kont (list))))
;; --------------------------------------------------------------------------
;; Extension points — custom special forms and render dispatch
;; --------------------------------------------------------------------------
;;
;; Extensions (web forms, type system, etc.) register handlers here.
;; The evaluator calls these from step-eval-list after core forms.
(define *custom-special-forms* (dict))
(define register-special-form!
(fn ((name :as string) handler)
(dict-set! *custom-special-forms* name handler)))
;; Render dispatch — installed by web adapters, nil when no renderer active.
;; *render-check*: (expr env) → boolean — should this expression be rendered?
;; *render-fn*: (expr env) → value — render and return result
(define *render-check* nil)
(define *render-fn* nil)
;; **************************************************************************
;; Part 2: Evaluation Utilities
;; **************************************************************************
@@ -545,6 +565,14 @@
(every? (fn (c) (and (= (type-of c) "list") (= (len c) 2)))
clauses)))
;; is-else-clause? — check if a cond/case test is an else marker
(define is-else-clause?
(fn (test)
(or (and (= (type-of test) "keyword") (= (keyword-name test) "else"))
(and (= (type-of test) "symbol")
(or (= (symbol-name test) "else")
(= (symbol-name test) ":else"))))))
;; Named let: (let name ((x 0) (y 1)) body...)
;; Desugars to a self-recursive lambda called with initial values.
@@ -755,91 +783,6 @@
(list params rest-param))))
(define sf-defstyle
(fn ((args :as list) (env :as dict))
;; (defstyle name expr) — bind name to evaluated expr (string, function, etc.)
(let ((name-sym (first args))
(value (trampoline (eval-expr (nth args 1) env))))
(env-bind! env (symbol-name name-sym) value)
value)))
;; -- deftype helpers (must be in eval.sx, not types.sx, because
;; sf-deftype is always compiled but types.sx is a spec module) --
(define make-type-def
(fn ((name :as string) (params :as list) body)
{:name name :params params :body body}))
(define normalize-type-body
(fn (body)
;; Convert AST type expressions to type representation.
;; Symbols → strings, (union ...) → (or ...), dict keys → strings.
(cond
(nil? body) "nil"
(= (type-of body) "symbol")
(symbol-name body)
(= (type-of body) "string")
body
(= (type-of body) "keyword")
(keyword-name body)
(= (type-of body) "dict")
;; Record type — normalize values
(map-dict (fn (k v) (normalize-type-body v)) body)
(= (type-of body) "list")
(if (empty? body) "any"
(let ((head (first body)))
(let ((head-name (if (= (type-of head) "symbol")
(symbol-name head) (str head))))
;; (union a b) → (or a b)
(if (= head-name "union")
(cons "or" (map normalize-type-body (rest body)))
;; (or a b), (list-of t), (-> ...) etc.
(cons head-name (map normalize-type-body (rest body)))))))
:else (str body))))
(define sf-deftype
(fn ((args :as list) (env :as dict))
;; (deftype name body) or (deftype (name a b ...) body)
(let ((name-or-form (first args))
(body-expr (nth args 1))
(type-name nil)
(type-params (list)))
;; Parse name — symbol or (symbol params...)
(if (= (type-of name-or-form) "symbol")
(set! type-name (symbol-name name-or-form))
(when (= (type-of name-or-form) "list")
(set! type-name (symbol-name (first name-or-form)))
(set! type-params
(map (fn (p) (if (= (type-of p) "symbol")
(symbol-name p) (str p)))
(rest name-or-form)))))
;; Normalize and store in *type-registry*
(let ((body (normalize-type-body body-expr))
(registry (if (env-has? env "*type-registry*")
(env-get env "*type-registry*")
(dict))))
(dict-set! registry type-name
(make-type-def type-name type-params body))
(env-bind! env "*type-registry*" registry)
nil))))
(define sf-defeffect
(fn ((args :as list) (env :as dict))
;; (defeffect name) — register an effect name
(let ((effect-name (if (= (type-of (first args)) "symbol")
(symbol-name (first args))
(str (first args))))
(registry (if (env-has? env "*effect-registry*")
(env-get env "*effect-registry*")
(list))))
(when (not (contains? registry effect-name))
(append! registry effect-name))
(env-bind! env "*effect-registry*" registry)
nil)))
(define qq-expand
(fn (template (env :as dict))
(if (not (= (type-of template) "list"))
@@ -1126,10 +1069,11 @@
;; (pop-wind!) → void (pop wind record from stack)
;; (call-thunk f env) → value (call a zero-arg function)
;;
;; Render-time accumulators:
;; (collect! bucket value) → void (add to named bucket, deduplicated)
;; (collected bucket) → list (all values in bucket)
;; (clear-collected! bucket) → void (empty the bucket)
;; Extension hooks (set by web adapters, type system, etc.):
;; *custom-special-forms* — dict of name → handler fn
;; register-special-form! — (name handler) → registers custom form
;; *render-check* — nil or (expr env) → boolean
;; *render-fn* — nil or (expr env) → value
;; --------------------------------------------------------------------------
@@ -1262,13 +1206,6 @@
(= name "defcomp") (make-cek-value (sf-defcomp args env) env kont)
(= name "defisland") (make-cek-value (sf-defisland args env) env kont)
(= name "defmacro") (make-cek-value (sf-defmacro args env) env kont)
(= name "defstyle") (make-cek-value (sf-defstyle args env) env kont)
(= name "defhandler") (make-cek-value (sf-defhandler args env) env kont)
(= name "defpage") (make-cek-value (sf-defpage args env) env kont)
(= name "defquery") (make-cek-value (sf-defquery args env) env kont)
(= name "defaction") (make-cek-value (sf-defaction args env) env kont)
(= name "deftype") (make-cek-value (sf-deftype args env) env kont)
(= name "defeffect") (make-cek-value (sf-defeffect args env) env kont)
(= name "begin") (step-sf-begin args env kont)
(= name "do") (step-sf-begin args env kont)
(= name "quote") (make-cek-value (if (empty? args) nil (first args)) env kont)
@@ -1303,14 +1240,20 @@
(= name "every?") (step-ho-every args env kont)
(= name "for-each") (step-ho-for-each args env kont)
;; Custom special forms (registered by extensions)
(has-key? *custom-special-forms* name)
(make-cek-value
((get *custom-special-forms* name) args env)
env kont)
;; Macro expansion
(and (env-has? env name) (macro? (env-get env name)))
(let ((mac (env-get env name)))
(make-cek-state (expand-macro mac args env) env kont))
;; Render expression
(and (render-active?) (is-render-expr? expr))
(make-cek-value (render-expr expr env) env kont)
;; Render dispatch (installed by web adapters)
(and *render-check* (*render-check* expr env))
(make-cek-value (*render-fn* expr env) env kont)
;; Fall through to function call
:else (step-eval-call head args env kont)))
@@ -1451,11 +1394,7 @@
(let ((clause (first args))
(test (first clause)))
;; Check for :else / else
(if (or (and (= (type-of test) "symbol")
(or (= (symbol-name test) "else")
(= (symbol-name test) ":else")))
(and (= (type-of test) "keyword")
(= (keyword-name test) "else")))
(if (is-else-clause? test)
(make-cek-state (nth clause 1) env kont)
(make-cek-state
test env
@@ -1464,10 +1403,7 @@
(if (< (len args) 2)
(make-cek-value nil env kont)
(let ((test (first args)))
(if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else"))
(and (= (type-of test) "symbol")
(or (= (symbol-name test) "else")
(= (symbol-name test) ":else"))))
(if (is-else-clause? test)
(make-cek-state (nth args 1) env kont)
(make-cek-state
test env
@@ -1950,11 +1886,7 @@
(make-cek-value nil fenv rest-k)
(let ((next-clause (first next-clauses))
(next-test (first next-clause)))
(if (or (and (= (type-of next-test) "symbol")
(or (= (symbol-name next-test) "else")
(= (symbol-name next-test) ":else")))
(and (= (type-of next-test) "keyword")
(= (keyword-name next-test) "else")))
(if (is-else-clause? next-test)
(make-cek-state (nth next-clause 1) fenv rest-k)
(make-cek-state
next-test fenv
@@ -1966,10 +1898,7 @@
(if (< (len next) 2)
(make-cek-value nil fenv rest-k)
(let ((next-test (first next)))
(if (or (and (= (type-of next-test) "keyword") (= (keyword-name next-test) "else"))
(and (= (type-of next-test) "symbol")
(or (= (symbol-name next-test) "else")
(= (symbol-name next-test) ":else"))))
(if (is-else-clause? next-test)
(make-cek-state (nth next 1) fenv rest-k)
(make-cek-state
next-test fenv
@@ -2336,10 +2265,7 @@
(make-cek-value nil env kont)
(let ((test (first clauses))
(body (nth clauses 1)))
(if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else"))
(and (= (type-of test) "symbol")
(or (= (symbol-name test) "else")
(= (symbol-name test) ":else"))))
(if (is-else-clause? test)
(make-cek-state body env kont)
;; Evaluate test expression
(let ((test-val (trampoline (eval-expr test env))))
@@ -2368,150 +2294,6 @@
val)))
;; --------------------------------------------------------------------------
;; 13. Freeze scopes — named serializable state boundaries
;; --------------------------------------------------------------------------
;;
;; A freeze scope collects signals registered within it. On freeze,
;; their current values are serialized to SX. On thaw, values are
;; restored. Multiple named scopes can coexist independently.
;;
;; Uses the scoped effects system: scope-push!/scope-pop!/context.
;;
;; Usage:
;; (freeze-scope "editor"
;; (let ((doc (signal "hello")))
;; (freeze-signal "doc" doc)
;; ...))
;;
;; (cek-freeze-scope "editor") → {:name "editor" :signals {:doc "hello"}}
;; (cek-thaw-scope "editor" frozen-data) → restores signal values
;; Registry of freeze scopes: name → list of {name signal} entries
(define freeze-registry (dict))
;; Register a signal in the current freeze scope
(define freeze-signal :effects [mutation]
(fn (name sig)
(let ((scope-name (context "sx-freeze-scope" nil)))
(when scope-name
(let ((entries (or (get freeze-registry scope-name) (list))))
(append! entries (dict "name" name "signal" sig))
(dict-set! freeze-registry scope-name entries))))))
;; Freeze scope delimiter — collects signals registered within body
(define freeze-scope :effects [mutation]
(fn (name body-fn)
(scope-push! "sx-freeze-scope" name)
;; Initialize empty entry list for this scope
(dict-set! freeze-registry name (list))
(cek-call body-fn nil)
(scope-pop! "sx-freeze-scope")
nil))
;; Freeze a named scope → SX dict of signal values
(define cek-freeze-scope :effects []
(fn (name)
(let ((entries (or (get freeze-registry name) (list)))
(signals-dict (dict)))
(for-each (fn (entry)
(dict-set! signals-dict
(get entry "name")
(signal-value (get entry "signal"))))
entries)
(dict "name" name "signals" signals-dict))))
;; Freeze all scopes
(define cek-freeze-all :effects []
(fn ()
(map (fn (name) (cek-freeze-scope name))
(keys freeze-registry))))
;; Thaw a named scope — restore signal values from frozen data
(define cek-thaw-scope :effects [mutation]
(fn (name frozen)
(let ((entries (or (get freeze-registry name) (list)))
(values (get frozen "signals")))
(when values
(for-each (fn (entry)
(let ((sig-name (get entry "name"))
(sig (get entry "signal"))
(val (get values sig-name)))
(when (not (nil? val))
(reset! sig val))))
entries)))))
;; Thaw all scopes from a list of frozen scope dicts
(define cek-thaw-all :effects [mutation]
(fn (frozen-list)
(for-each (fn (frozen)
(cek-thaw-scope (get frozen "name") frozen))
frozen-list)))
;; Serialize a frozen scope to SX text
(define freeze-to-sx :effects []
(fn (name)
(sx-serialize (cek-freeze-scope name))))
;; Restore from SX text
(define thaw-from-sx :effects [mutation]
(fn (sx-text)
(let ((parsed (sx-parse sx-text)))
(when (not (empty? parsed))
(let ((frozen (first parsed)))
(cek-thaw-scope (get frozen "name") frozen))))))
;; --------------------------------------------------------------------------
;; 14. Content-addressed computation
;; --------------------------------------------------------------------------
;;
;; Hash frozen SX to a content identifier. Store and retrieve by CID.
;; The content IS the address — same SX always produces the same CID.
;;
;; Uses an in-memory content store. Applications can persist to
;; localStorage or IPFS by providing their own store backend.
(define content-store (dict))
(define content-hash :effects []
(fn (sx-text)
;; djb2 hash → hex string. Simple, deterministic, fast.
;; Real deployment would use SHA-256 / multihash.
(let ((hash 5381))
(for-each (fn (i)
(set! hash (mod (+ (* hash 33) (char-code-at sx-text i)) 4294967296)))
(range 0 (len sx-text)))
(to-hex hash))))
(define content-put :effects [mutation]
(fn (sx-text)
(let ((cid (content-hash sx-text)))
(dict-set! content-store cid sx-text)
cid)))
(define content-get :effects []
(fn (cid)
(get content-store cid)))
;; Freeze a scope → store → return CID
(define freeze-to-cid :effects [mutation]
(fn (scope-name)
(let ((sx-text (freeze-to-sx scope-name)))
(content-put sx-text))))
;; Thaw from CID → look up → restore
(define thaw-from-cid :effects [mutation]
(fn (cid)
(let ((sx-text (content-get cid)))
(when sx-text
(thaw-from-sx sx-text)
true))))
;; **************************************************************************
;; eval-expr / trampoline — canonical definitions (after cek-run is defined)
;; **************************************************************************

94
spec/freeze.sx Normal file
View File

@@ -0,0 +1,94 @@
;; ==========================================================================
;; freeze.sx — Serializable state boundaries
;;
;; Freeze scopes collect signals registered within them. On freeze,
;; their current values are serialized to SX. On thaw, values are
;; restored. Multiple named scopes can coexist independently.
;;
;; This is a library built on top of the evaluator's scoped effects
;; (scope-push!/scope-pop!/context) and signal system. It is NOT
;; part of the core evaluator — it loads after evaluator.sx.
;;
;; Usage:
;; (freeze-scope "editor"
;; (let ((doc (signal "hello")))
;; (freeze-signal "doc" doc)
;; ...))
;;
;; (cek-freeze-scope "editor") → {:name "editor" :signals {:doc "hello"}}
;; (cek-thaw-scope "editor" frozen-data) → restores signal values
;; ==========================================================================
;; Registry of freeze scopes: name → list of {name signal} entries
(define freeze-registry (dict))
;; Register a signal in the current freeze scope
(define freeze-signal :effects [mutation]
(fn (name sig)
(let ((scope-name (context "sx-freeze-scope" nil)))
(when scope-name
(let ((entries (or (get freeze-registry scope-name) (list))))
(append! entries (dict "name" name "signal" sig))
(dict-set! freeze-registry scope-name entries))))))
;; Freeze scope delimiter — collects signals registered within body
(define freeze-scope :effects [mutation]
(fn (name body-fn)
(scope-push! "sx-freeze-scope" name)
;; Initialize empty entry list for this scope
(dict-set! freeze-registry name (list))
(cek-call body-fn nil)
(scope-pop! "sx-freeze-scope")
nil))
;; Freeze a named scope → SX dict of signal values
(define cek-freeze-scope :effects []
(fn (name)
(let ((entries (or (get freeze-registry name) (list)))
(signals-dict (dict)))
(for-each (fn (entry)
(dict-set! signals-dict
(get entry "name")
(signal-value (get entry "signal"))))
entries)
(dict "name" name "signals" signals-dict))))
;; Freeze all scopes
(define cek-freeze-all :effects []
(fn ()
(map (fn (name) (cek-freeze-scope name))
(keys freeze-registry))))
;; Thaw a named scope — restore signal values from frozen data
(define cek-thaw-scope :effects [mutation]
(fn (name frozen)
(let ((entries (or (get freeze-registry name) (list)))
(values (get frozen "signals")))
(when values
(for-each (fn (entry)
(let ((sig-name (get entry "name"))
(sig (get entry "signal"))
(val (get values sig-name)))
(when (not (nil? val))
(reset! sig val))))
entries)))))
;; Thaw all scopes from a list of frozen scope dicts
(define cek-thaw-all :effects [mutation]
(fn (frozen-list)
(for-each (fn (frozen)
(cek-thaw-scope (get frozen "name") frozen))
frozen-list)))
;; Serialize a frozen scope to SX text
(define freeze-to-sx :effects []
(fn (name)
(sx-serialize (cek-freeze-scope name))))
;; Restore from SX text
(define thaw-from-sx :effects [mutation]
(fn (sx-text)
(let ((parsed (sx-parse sx-text)))
(when (not (empty? parsed))
(let ((frozen (first parsed)))
(cek-thaw-scope (get frozen "name") frozen))))))

View File

@@ -146,11 +146,7 @@
(let ((clause (first clauses))
(test (first clause))
(body (nth clause 1)))
(if (or (and (= (type-of test) "symbol")
(or (= (symbol-name test) "else")
(= (symbol-name test) ":else")))
(and (= (type-of test) "keyword")
(= (keyword-name test) "else")))
(if (is-else-clause? test)
body
(if (trampoline (eval-expr test env))
body
@@ -162,10 +158,7 @@
nil
(let ((test (first clauses))
(body (nth clauses 1)))
(if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else"))
(and (= (type-of test) "symbol")
(or (= (symbol-name test) "else")
(= (symbol-name test) ":else"))))
(if (is-else-clause? test)
body
(if (trampoline (eval-expr test env))
body
@@ -250,13 +243,28 @@
(keys spread-dict))))
;; --------------------------------------------------------------------------
;; HTML escaping — library functions (pure text processing)
;; --------------------------------------------------------------------------
(define escape-html
(fn (s)
(-> (str s)
(replace "&" "&amp;")
(replace "<" "&lt;")
(replace ">" "&gt;")
(replace "\"" "&quot;"))))
(define escape-attr
(fn (s)
(escape-html s)))
;; --------------------------------------------------------------------------
;; Platform interface (shared across adapters)
;; --------------------------------------------------------------------------
;;
;; HTML/attribute escaping (used by HTML and SX wire adapters):
;; (escape-html s) → HTML-escaped string
;; (escape-attr s) → attribute-value-escaped string
;; Raw HTML (marker type for unescaped content):
;; (raw-html-content r) → unwrap RawHTML marker to string
;;
;; Spread (render-time attribute injection):

View File

@@ -566,181 +566,3 @@
(assert-equal 0 (len (list)))
(assert-equal "" (str))))
;; --------------------------------------------------------------------------
;; Server-only tests — skip in browser (defpage, streaming functions)
;; These require forms.sx which is only loaded server-side.
;; --------------------------------------------------------------------------
(when (get (try-call (fn () stream-chunk-id)) "ok")
(defsuite "defpage"
(deftest "basic defpage returns page-def"
(let ((p (defpage test-basic :path "/test" :auth :public :content (div "hello"))))
(assert-true (not (nil? p)))
(assert-equal "test-basic" (get p "name"))
(assert-equal "/test" (get p "path"))
(assert-equal "public" (get p "auth"))))
(deftest "defpage content expr is unevaluated AST"
(let ((p (defpage test-content :path "/c" :auth :public :content (~my-comp :title "hi"))))
(assert-true (not (nil? (get p "content"))))))
(deftest "defpage with :stream"
(let ((p (defpage test-stream :path "/s" :auth :public :stream true :content (div "x"))))
(assert-equal true (get p "stream"))))
(deftest "defpage with :shell"
(let ((p (defpage test-shell :path "/sh" :auth :public :stream true
:shell (~my-layout (~suspense :id "data" :fallback (div "loading...")))
:content (~my-streamed :data data-val))))
(assert-true (not (nil? (get p "shell"))))
(assert-true (not (nil? (get p "content"))))))
(deftest "defpage with :fallback"
(let ((p (defpage test-fallback :path "/f" :auth :public :stream true
:fallback (div :class "skeleton" "loading")
:content (div "done"))))
(assert-true (not (nil? (get p "fallback"))))))
(deftest "defpage with :data"
(let ((p (defpage test-data :path "/d" :auth :public
:data (fetch-items)
:content (~items-list :items items))))
(assert-true (not (nil? (get p "data"))))))
(deftest "defpage missing fields are nil"
(let ((p (defpage test-minimal :path "/m" :auth :public :content (div "x"))))
(assert-nil (get p "data"))
(assert-nil (get p "filter"))
(assert-nil (get p "aside"))
(assert-nil (get p "menu"))
(assert-nil (get p "shell"))
(assert-nil (get p "fallback"))
(assert-equal false (get p "stream")))))
;; --------------------------------------------------------------------------
;; Multi-stream data protocol (from forms.sx)
;; --------------------------------------------------------------------------
(defsuite "stream-chunk-id"
(deftest "extracts stream-id from chunk"
(assert-equal "my-slot" (stream-chunk-id {"stream-id" "my-slot" "x" 1})))
(deftest "defaults to stream-content when missing"
(assert-equal "stream-content" (stream-chunk-id {"x" 1 "y" 2}))))
(defsuite "stream-chunk-bindings"
(deftest "removes stream-id from chunk"
(let ((bindings (stream-chunk-bindings {"stream-id" "slot" "name" "alice" "age" 30})))
(assert-equal "alice" (get bindings "name"))
(assert-equal 30 (get bindings "age"))
(assert-nil (get bindings "stream-id"))))
(deftest "returns all keys when no stream-id"
(let ((bindings (stream-chunk-bindings {"a" 1 "b" 2})))
(assert-equal 1 (get bindings "a"))
(assert-equal 2 (get bindings "b")))))
(defsuite "normalize-binding-key"
(deftest "converts underscores to hyphens"
(assert-equal "my-key" (normalize-binding-key "my_key")))
(deftest "leaves hyphens unchanged"
(assert-equal "my-key" (normalize-binding-key "my-key")))
(deftest "handles multiple underscores"
(assert-equal "a-b-c" (normalize-binding-key "a_b_c"))))
(defsuite "bind-stream-chunk"
(deftest "creates fresh env with bindings"
(let ((base {"existing" 42})
(chunk {"stream-id" "slot" "user-name" "bob" "count" 5})
(env (bind-stream-chunk chunk base)))
;; Base env bindings are preserved
(assert-equal 42 (get env "existing"))
;; Chunk bindings are added (stream-id removed)
(assert-equal "bob" (get env "user-name"))
(assert-equal 5 (get env "count"))
;; stream-id is not in env
(assert-nil (get env "stream-id"))))
(deftest "isolates env from base — bindings don't leak to base"
(let ((base {"x" 1})
(chunk {"stream-id" "s" "y" 2})
(env (bind-stream-chunk chunk base)))
;; Chunk bindings should not appear in base
(assert-nil (get base "y"))
;; Base bindings should be in derived env
(assert-equal 1 (get env "x")))))
(defsuite "validate-stream-data"
(deftest "valid: list of dicts"
(assert-true (validate-stream-data
(list {"stream-id" "a" "x" 1}
{"stream-id" "b" "y" 2}))))
(deftest "valid: empty list"
(assert-true (validate-stream-data (list))))
(deftest "invalid: single dict (not a list)"
(assert-equal false (validate-stream-data {"x" 1})))
(deftest "invalid: list containing non-dict"
(assert-equal false (validate-stream-data (list {"x" 1} "oops" {"y" 2})))))
;; --------------------------------------------------------------------------
;; Multi-stream end-to-end scenarios
;; --------------------------------------------------------------------------
(defsuite "multi-stream routing"
(deftest "stream-chunk-id routes different chunks to different slots"
(let ((chunks (list
{"stream-id" "stream-fast" "msg" "quick"}
{"stream-id" "stream-medium" "msg" "steady"}
{"stream-id" "stream-slow" "msg" "slow"}))
(ids (map stream-chunk-id chunks)))
(assert-equal "stream-fast" (nth ids 0))
(assert-equal "stream-medium" (nth ids 1))
(assert-equal "stream-slow" (nth ids 2))))
(deftest "bind-stream-chunk creates isolated envs per chunk"
(let ((base {"layout" "main"})
(chunk-a {"stream-id" "a" "title" "First" "count" 1})
(chunk-b {"stream-id" "b" "title" "Second" "count" 2})
(env-a (bind-stream-chunk chunk-a base))
(env-b (bind-stream-chunk chunk-b base)))
;; Each env has its own bindings
(assert-equal "First" (get env-a "title"))
(assert-equal "Second" (get env-b "title"))
(assert-equal 1 (get env-a "count"))
(assert-equal 2 (get env-b "count"))
;; Both share base
(assert-equal "main" (get env-a "layout"))
(assert-equal "main" (get env-b "layout"))
;; Neither leaks into base
(assert-nil (get base "title"))))
(deftest "normalize-binding-key applied to chunk keys"
(let ((chunk {"stream-id" "s" "user_name" "alice" "item_count" 3})
(bindings (stream-chunk-bindings chunk)))
;; Keys with underscores need normalizing for SX env
(assert-equal "alice" (get bindings "user_name"))
;; normalize-binding-key converts them
(assert-equal "user-name" (normalize-binding-key "user_name"))
(assert-equal "item-count" (normalize-binding-key "item_count"))))
(deftest "defpage stream flag defaults to false"
(let ((p (defpage test-no-stream :path "/ns" :auth :public :content (div "x"))))
(assert-equal false (get p "stream"))))
(deftest "defpage stream true recorded in page-def"
(let ((p (defpage test-with-stream :path "/ws" :auth :public
:stream true
:shell (~layout (~suspense :id "data"))
:content (~chunk :val val))))
(assert-equal true (get p "stream"))
(assert-true (not (nil? (get p "shell")))))))
) ;; end (when has-server-forms?)

View File

@@ -4,10 +4,13 @@
;; Registration-time type checking: zero runtime cost.
;; Annotations are optional — unannotated code defaults to `any`.
;;
;; Depends on: eval.sx (type-of, component accessors, env ops)
;; This is an optional spec module — NOT part of the core evaluator.
;; It registers deftype and defeffect via register-special-form! at load time.
;;
;; Depends on: evaluator.sx (type-of, component accessors, env ops)
;; primitives.sx, boundary.sx (return type declarations)
;;
;; Platform interface (from eval.sx, already provided):
;; Platform interface (from evaluator.sx, already provided):
;; (type-of x) → type string
;; (symbol-name s) → string
;; (keyword-name k) → string
@@ -22,6 +25,88 @@
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 0. Definition forms — deftype and defeffect
;; --------------------------------------------------------------------------
;; These were previously in evaluator.sx. Now they live here and register
;; themselves via the custom special form mechanism.
(define make-type-def
(fn ((name :as string) (params :as list) body)
{:name name :params params :body body}))
(define normalize-type-body
(fn (body)
;; Convert AST type expressions to type representation.
;; Symbols → strings, (union ...) → (or ...), dict keys → strings.
(cond
(nil? body) "nil"
(= (type-of body) "symbol")
(symbol-name body)
(= (type-of body) "string")
body
(= (type-of body) "keyword")
(keyword-name body)
(= (type-of body) "dict")
;; Record type — normalize values
(map-dict (fn (k v) (normalize-type-body v)) body)
(= (type-of body) "list")
(if (empty? body) "any"
(let ((head (first body)))
(let ((head-name (if (= (type-of head) "symbol")
(symbol-name head) (str head))))
;; (union a b) → (or a b)
(if (= head-name "union")
(cons "or" (map normalize-type-body (rest body)))
;; (or a b), (list-of t), (-> ...) etc.
(cons head-name (map normalize-type-body (rest body)))))))
:else (str body))))
(define sf-deftype
(fn ((args :as list) (env :as dict))
;; (deftype name body) or (deftype (name a b ...) body)
(let ((name-or-form (first args))
(body-expr (nth args 1))
(type-name nil)
(type-params (list)))
;; Parse name — symbol or (symbol params...)
(if (= (type-of name-or-form) "symbol")
(set! type-name (symbol-name name-or-form))
(when (= (type-of name-or-form) "list")
(set! type-name (symbol-name (first name-or-form)))
(set! type-params
(map (fn (p) (if (= (type-of p) "symbol")
(symbol-name p) (str p)))
(rest name-or-form)))))
;; Normalize and store in *type-registry*
(let ((body (normalize-type-body body-expr))
(registry (if (env-has? env "*type-registry*")
(env-get env "*type-registry*")
(dict))))
(dict-set! registry type-name
(make-type-def type-name type-params body))
(env-bind! env "*type-registry*" registry)
nil))))
(define sf-defeffect
(fn ((args :as list) (env :as dict))
;; (defeffect name) — register an effect name
(let ((effect-name (if (= (type-of (first args)) "symbol")
(symbol-name (first args))
(str (first args))))
(registry (if (env-has? env "*effect-registry*")
(env-get env "*effect-registry*")
(list))))
(when (not (contains? registry effect-name))
(append! registry effect-name))
(env-bind! env "*effect-registry*" registry)
nil)))
;; Register as custom special forms
(register-special-form! "deftype" sf-deftype)
(register-special-form! "defeffect" sf-defeffect)
;; --------------------------------------------------------------------------
;; 1. Type representation
;; --------------------------------------------------------------------------