Separate core spec from web framework
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m49s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m49s
Three-layer architecture:
spec/ — Core language (19 files): evaluator, parser, primitives,
CEK machine, types, continuations. Host-independent.
web/ — Web framework (20 files): signals, adapters, engine,
orchestration, boot, router, CSSX. Built on core spec.
sx/ — Application (sx-docs website). Built on web framework.
Split boundary.sx into boundary-core.sx (type-of, make-env, identical?)
and boundary-web.sx (IO primitives, signals, spreads, page helpers).
Bootstrappers search spec/ → web/ → shared/sx/ref/ for .sx files.
Original files remain in shared/sx/ref/ as fallback during transition.
All 63 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1325,8 +1325,16 @@ except ImportError:
|
||||
)
|
||||
|
||||
|
||||
def _parse_special_forms_spec(ref_dir: str) -> set[str]:
|
||||
def _parse_special_forms_spec(ref_dir: str, source_dirs=None) -> set[str]:
|
||||
"""Parse special-forms.sx to extract declared form names."""
|
||||
filepath = None
|
||||
if source_dirs:
|
||||
for d in source_dirs:
|
||||
p = os.path.join(d, "special-forms.sx")
|
||||
if os.path.exists(p):
|
||||
filepath = p
|
||||
break
|
||||
if not filepath:
|
||||
filepath = os.path.join(ref_dir, "special-forms.sx")
|
||||
if not os.path.exists(filepath):
|
||||
return set()
|
||||
@@ -1359,9 +1367,9 @@ def _extract_eval_dispatch_names(all_sections: list) -> set[str]:
|
||||
|
||||
|
||||
def _validate_special_forms(ref_dir: str, all_sections: list,
|
||||
has_continuations: bool) -> None:
|
||||
has_continuations: bool, source_dirs=None) -> None:
|
||||
"""Cross-check special-forms.sx against eval.sx dispatch. Warn on mismatches."""
|
||||
spec_names = _parse_special_forms_spec(ref_dir)
|
||||
spec_names = _parse_special_forms_spec(ref_dir, source_dirs=source_dirs)
|
||||
if not spec_names:
|
||||
return
|
||||
|
||||
@@ -1432,6 +1440,20 @@ def compile_ref_to_py(
|
||||
prim_modules.append(m)
|
||||
|
||||
ref_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_project = os.path.abspath(os.path.join(ref_dir, "..", "..", ".."))
|
||||
_source_dirs = [
|
||||
os.path.join(_project, "spec"),
|
||||
os.path.join(_project, "web"),
|
||||
ref_dir,
|
||||
]
|
||||
|
||||
def _find_sx(filename):
|
||||
for d in _source_dirs:
|
||||
p = os.path.join(d, filename)
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
emitter = PyEmitter()
|
||||
|
||||
# Resolve adapter set
|
||||
@@ -1494,7 +1516,7 @@ def compile_ref_to_py(
|
||||
has_async = "async" in adapter_set
|
||||
if has_async:
|
||||
async_filename = ADAPTER_FILES["async"][0]
|
||||
async_filepath = os.path.join(ref_dir, async_filename)
|
||||
async_filepath = _find_sx(async_filename) or os.path.join(ref_dir, async_filename)
|
||||
if os.path.exists(async_filepath):
|
||||
with open(async_filepath) as f:
|
||||
async_src = f.read()
|
||||
@@ -1513,7 +1535,7 @@ def compile_ref_to_py(
|
||||
|
||||
all_sections = []
|
||||
for filename, label in sx_files:
|
||||
filepath = os.path.join(ref_dir, filename)
|
||||
filepath = _find_sx(filename) or os.path.join(ref_dir, filename)
|
||||
if not os.path.exists(filepath):
|
||||
continue
|
||||
with open(filepath) as f:
|
||||
@@ -1531,7 +1553,7 @@ def compile_ref_to_py(
|
||||
has_continuations = "continuations" in ext_set
|
||||
|
||||
# Validate special forms
|
||||
_validate_special_forms(ref_dir, all_sections, has_continuations)
|
||||
_validate_special_forms(ref_dir, all_sections, has_continuations, source_dirs=_source_dirs)
|
||||
|
||||
# Build output
|
||||
has_html = "html" in adapter_set
|
||||
|
||||
@@ -78,6 +78,13 @@ def compile_ref_to_js(
|
||||
from shared.sx.ref.sx_ref import evaluate
|
||||
|
||||
ref_dir = _HERE
|
||||
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
|
||||
# Source directories: core spec, web framework, and legacy ref (for bootstrapper tools)
|
||||
_source_dirs = [
|
||||
os.path.join(_PROJECT, "spec"), # Core spec
|
||||
os.path.join(_PROJECT, "web"), # Web framework
|
||||
ref_dir, # Legacy location (fallback)
|
||||
]
|
||||
env = load_js_sx()
|
||||
|
||||
# Resolve adapter set
|
||||
@@ -195,9 +202,16 @@ def compile_ref_to_js(
|
||||
parts.append(PLATFORM_CEK_JS)
|
||||
|
||||
# Translate each spec file using js.sx
|
||||
def _find_sx(filename):
|
||||
for d in _source_dirs:
|
||||
p = os.path.join(d, filename)
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
for filename, label in sx_files:
|
||||
filepath = os.path.join(ref_dir, filename)
|
||||
if not os.path.exists(filepath):
|
||||
filepath = _find_sx(filename)
|
||||
if not filepath:
|
||||
continue
|
||||
with open(filepath) as f:
|
||||
src = f.read()
|
||||
|
||||
49
spec/boundary-core.sx
Normal file
49
spec/boundary-core.sx
Normal file
@@ -0,0 +1,49 @@
|
||||
;; ==========================================================================
|
||||
;; boundary.sx — SX language boundary contract
|
||||
;;
|
||||
;; Declares the core I/O primitives that any SX host must provide.
|
||||
;; This is the LANGUAGE contract — not deployment-specific.
|
||||
;;
|
||||
;; Pure primitives (Tier 1) are declared in primitives.sx.
|
||||
;; Deployment-specific I/O lives in boundary-app.sx.
|
||||
;; Per-service page helpers live in {service}/sx/boundary.sx.
|
||||
;;
|
||||
;; Format:
|
||||
;; (define-io-primitive "name"
|
||||
;; :params (param1 param2 &key ...)
|
||||
;; :returns "type"
|
||||
;; :effects [io]
|
||||
;; :async true
|
||||
;; :doc "description"
|
||||
;; :context :request)
|
||||
;;
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 1: Pure primitives — declared in primitives.sx
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(declare-tier :pure :source "primitives.sx")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Core platform primitives — type inspection + environment
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; These are provided by every host. Not IO, not web-specific.
|
||||
|
||||
(declare-core-primitive "type-of"
|
||||
:params (value)
|
||||
:returns "string"
|
||||
:doc "Return the type name of a value.")
|
||||
|
||||
(declare-core-primitive "make-env"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:doc "Create a base environment with all primitives.")
|
||||
|
||||
(declare-core-primitive "identical?"
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Reference equality check.")
|
||||
245
spec/callcc.sx
Normal file
245
spec/callcc.sx
Normal file
@@ -0,0 +1,245 @@
|
||||
;; ==========================================================================
|
||||
;; callcc.sx — Full first-class continuations (call/cc)
|
||||
;;
|
||||
;; OPTIONAL EXTENSION — not required by the core evaluator.
|
||||
;; Bootstrappers include this only when the target supports it naturally.
|
||||
;;
|
||||
;; Full call/cc (call-with-current-continuation) captures the ENTIRE
|
||||
;; remaining computation as a first-class function — not just up to a
|
||||
;; delimiter, but all the way to the top level. Invoking a continuation
|
||||
;; captured by call/cc abandons the current computation entirely and
|
||||
;; resumes from where the continuation was captured.
|
||||
;;
|
||||
;; This is strictly more powerful than delimited continuations (shift/reset)
|
||||
;; but harder to implement in targets that don't support it natively.
|
||||
;; Recommended only for targets where it's natural:
|
||||
;; - Scheme/Racket (native call/cc)
|
||||
;; - Haskell (ContT monad transformer)
|
||||
;;
|
||||
;; For targets like Python, JavaScript, and Rust, delimited continuations
|
||||
;; (continuations.sx) are more practical and cover the same use cases
|
||||
;; without requiring a global CPS transform.
|
||||
;;
|
||||
;; One new special form:
|
||||
;; (call/cc f) — call f with the current continuation
|
||||
;;
|
||||
;; One new type:
|
||||
;; continuation — same type as in continuations.sx
|
||||
;;
|
||||
;; If both extensions are loaded, the continuation type is shared.
|
||||
;; Delimited and undelimited continuations are the same type —
|
||||
;; the difference is in how they are captured, not what they are.
|
||||
;;
|
||||
;; Platform requirements:
|
||||
;; (make-continuation fn) — wrap a function as a continuation value
|
||||
;; (continuation? x) — type predicate
|
||||
;; (type-of continuation) → "continuation"
|
||||
;; (call-with-cc f env) — target-specific call/cc implementation
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Semantics
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (call/cc f)
|
||||
;;
|
||||
;; Evaluates f (which must be a function of one argument), passing it the
|
||||
;; current continuation as a continuation value. f can:
|
||||
;;
|
||||
;; a) Return normally — call/cc returns whatever f returns
|
||||
;; b) Invoke the continuation — abandons f's computation, call/cc
|
||||
;; "returns" the value passed to the continuation
|
||||
;; c) Store the continuation — invoke it later, possibly multiple times
|
||||
;;
|
||||
;; Key difference from shift/reset: invoking an undelimited continuation
|
||||
;; NEVER RETURNS to the caller. It abandons the current computation and
|
||||
;; jumps back to where call/cc was originally called.
|
||||
;;
|
||||
;; ;; Delimited (shift/reset) — k returns a value:
|
||||
;; (reset (+ 1 (shift k (+ (k 10) (k 20)))))
|
||||
;; ;; (k 10) → 11, returns to the (+ ... (k 20)) expression
|
||||
;; ;; (k 20) → 21, returns to the (+ 11 ...) expression
|
||||
;; ;; result: 32
|
||||
;;
|
||||
;; ;; Undelimited (call/cc) — k does NOT return:
|
||||
;; (+ 1 (call/cc (fn (k)
|
||||
;; (+ (k 10) (k 20)))))
|
||||
;; ;; (k 10) abandons (+ (k 10) (k 20)) entirely
|
||||
;; ;; jumps back to (+ 1 _) with 10
|
||||
;; ;; result: 11
|
||||
;; ;; (k 20) is never reached
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. call/cc — call with current continuation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-callcc
|
||||
(fn (args env)
|
||||
;; Single argument: a function to call with the current continuation.
|
||||
(let ((f-expr (first args))
|
||||
(f (trampoline (eval-expr f-expr env))))
|
||||
(call-with-cc f env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Derived forms
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; With call/cc available, several patterns become expressible:
|
||||
;;
|
||||
;; --- Early return ---
|
||||
;;
|
||||
;; (define find-first
|
||||
;; (fn (pred items)
|
||||
;; (call/cc (fn (return)
|
||||
;; (for-each (fn (item)
|
||||
;; (when (pred item)
|
||||
;; (return item)))
|
||||
;; items)
|
||||
;; nil))))
|
||||
;;
|
||||
;; --- Exception-like flow ---
|
||||
;;
|
||||
;; (define try-catch
|
||||
;; (fn (body handler)
|
||||
;; (call/cc (fn (throw)
|
||||
;; (body throw)))))
|
||||
;;
|
||||
;; (try-catch
|
||||
;; (fn (throw)
|
||||
;; (let ((result (dangerous-operation)))
|
||||
;; (when (not result) (throw "failed"))
|
||||
;; result))
|
||||
;; (fn (error) (str "Caught: " error)))
|
||||
;;
|
||||
;; --- Coroutines ---
|
||||
;;
|
||||
;; Two call/cc captures that alternate control between two
|
||||
;; computations. Each captures its own continuation, then invokes
|
||||
;; the other's. This gives cooperative multitasking without threads.
|
||||
;;
|
||||
;; --- Undo ---
|
||||
;;
|
||||
;; (define with-undo
|
||||
;; (fn (action)
|
||||
;; (call/cc (fn (restore)
|
||||
;; (action)
|
||||
;; restore))))
|
||||
;;
|
||||
;; ;; (let ((undo (with-undo (fn () (delete-item 42)))))
|
||||
;; ;; (undo "anything")) → item 42 is back
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Interaction with delimited continuations
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; If both callcc.sx and continuations.sx are loaded:
|
||||
;;
|
||||
;; - The continuation type is shared. (continuation? k) returns true
|
||||
;; for both delimited and undelimited continuations.
|
||||
;;
|
||||
;; - shift inside a call/cc body captures up to the nearest reset,
|
||||
;; not up to the call/cc. The two mechanisms compose.
|
||||
;;
|
||||
;; - call/cc inside a reset body captures the entire continuation
|
||||
;; (past the reset). This is the expected behavior — call/cc is
|
||||
;; undelimited by definition.
|
||||
;;
|
||||
;; - A delimited continuation (from shift) returns a value when invoked.
|
||||
;; An undelimited continuation (from call/cc) does not return.
|
||||
;; Both are callable with the same syntax: (k value).
|
||||
;; The caller cannot distinguish them by type — only by behavior.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Interaction with I/O and state
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Full call/cc has well-known interactions with side effects:
|
||||
;;
|
||||
;; Re-entry:
|
||||
;; Invoking a saved continuation re-enters a completed computation.
|
||||
;; If that computation mutated state (set!, I/O writes), the mutations
|
||||
;; are NOT undone. The continuation resumes in the current state,
|
||||
;; not the state at the time of capture.
|
||||
;;
|
||||
;; I/O:
|
||||
;; Same as delimited continuations — I/O executes at invocation time.
|
||||
;; A continuation containing (current-user) will call current-user
|
||||
;; when invoked, in whatever request context exists then.
|
||||
;;
|
||||
;; Dynamic extent:
|
||||
;; call/cc captures the continuation, not the dynamic environment.
|
||||
;; Host-language context (Python's Quart request context, JavaScript's
|
||||
;; async context) may not be valid when a saved continuation is invoked
|
||||
;; later. Typed targets can enforce this; dynamic targets fail at runtime.
|
||||
;;
|
||||
;; Recommendation:
|
||||
;; Use call/cc for pure control flow (early return, coroutines,
|
||||
;; backtracking). Use delimited continuations for effectful patterns
|
||||
;; (suspense, cooperative scheduling) where the delimiter provides
|
||||
;; a natural boundary.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Implementation notes per target
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Scheme / Racket:
|
||||
;; Native call/cc. Zero implementation effort.
|
||||
;;
|
||||
;; Haskell:
|
||||
;; ContT monad transformer. The evaluator runs in ContT, and call/cc
|
||||
;; is callCC from Control.Monad.Cont. Natural and type-safe.
|
||||
;;
|
||||
;; Python:
|
||||
;; Requires full CPS transform of the evaluator, or greenlet-based
|
||||
;; stack capture. Significantly more invasive than delimited
|
||||
;; continuations. NOT RECOMMENDED — use continuations.sx instead.
|
||||
;;
|
||||
;; JavaScript:
|
||||
;; Requires full CPS transform. Cannot be implemented with generators
|
||||
;; alone (generators only support delimited yield, not full escape).
|
||||
;; NOT RECOMMENDED — use continuations.sx instead.
|
||||
;;
|
||||
;; Rust:
|
||||
;; Full CPS transform at compile time. Possible but adds significant
|
||||
;; complexity. Delimited continuations are more natural (enum-based).
|
||||
;; Consider only if the target genuinely needs undelimited escape.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. Platform interface — what each target must provide
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (call-with-cc f env)
|
||||
;; Call f with the current continuation. f is a function of one
|
||||
;; argument (the continuation). If f returns normally, call-with-cc
|
||||
;; returns f's result. If f invokes the continuation, the computation
|
||||
;; jumps to the call-with-cc call site with the provided value.
|
||||
;;
|
||||
;; (make-continuation fn)
|
||||
;; Wrap a native function as a continuation value.
|
||||
;; (Shared with continuations.sx if both are loaded.)
|
||||
;;
|
||||
;; (continuation? x)
|
||||
;; Type predicate.
|
||||
;; (Shared with continuations.sx if both are loaded.)
|
||||
;;
|
||||
;; Continuations must be callable via the standard function-call
|
||||
;; dispatch in eval-list (same path as lambda calls).
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
1178
spec/cek.sx
Normal file
1178
spec/cek.sx
Normal file
File diff suppressed because it is too large
Load Diff
248
spec/continuations.sx
Normal file
248
spec/continuations.sx
Normal file
@@ -0,0 +1,248 @@
|
||||
;; ==========================================================================
|
||||
;; continuations.sx — Delimited continuations (shift/reset)
|
||||
;;
|
||||
;; OPTIONAL EXTENSION — not required by the core evaluator.
|
||||
;; Bootstrappers include this only when the target requests it.
|
||||
;;
|
||||
;; Delimited continuations capture "the rest of the computation up to
|
||||
;; a delimiter." They are strictly less powerful than full call/cc but
|
||||
;; cover the practical use cases: suspendable rendering, cooperative
|
||||
;; scheduling, linear async flows, wizard forms, and undo.
|
||||
;;
|
||||
;; Two new special forms:
|
||||
;; (reset body) — establish a delimiter
|
||||
;; (shift k body) — capture the continuation to the nearest reset
|
||||
;;
|
||||
;; One new type:
|
||||
;; continuation — a captured delimited continuation, callable
|
||||
;;
|
||||
;; The captured continuation is a function of one argument. Invoking it
|
||||
;; provides the value that the shift expression "returns" within the
|
||||
;; delimited context, then completes the rest of the reset body.
|
||||
;;
|
||||
;; Continuations are composable — invoking a continuation returns a
|
||||
;; value (the result of the reset body), which can be used normally.
|
||||
;; This is the key difference from undelimited call/cc, where invoking
|
||||
;; a continuation never returns.
|
||||
;;
|
||||
;; Platform requirements:
|
||||
;; (make-continuation fn) — wrap a function as a continuation value
|
||||
;; (continuation? x) — type predicate
|
||||
;; (type-of continuation) → "continuation"
|
||||
;; Continuations are callable (same dispatch as lambda).
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Type
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; A continuation is a callable value of one argument.
|
||||
;;
|
||||
;; (continuation? k) → true if k is a captured continuation
|
||||
;; (type-of k) → "continuation"
|
||||
;; (k value) → invoke: resume the captured computation with value
|
||||
;;
|
||||
;; Continuations are first-class: they can be stored in variables, passed
|
||||
;; as arguments, returned from functions, and put in data structures.
|
||||
;;
|
||||
;; Invoking a delimited continuation RETURNS a value — the result of the
|
||||
;; reset body. This makes them composable:
|
||||
;;
|
||||
;; (+ 1 (reset (+ 10 (shift k (k 5)))))
|
||||
;; ;; k is "add 10 to _ and return from reset"
|
||||
;; ;; (k 5) → 15, which is returned from reset
|
||||
;; ;; (+ 1 15) → 16
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. reset — establish a continuation delimiter
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (reset body)
|
||||
;;
|
||||
;; Evaluates body in the current environment. If no shift occurs during
|
||||
;; evaluation of body, reset simply returns the value of body.
|
||||
;;
|
||||
;; If shift occurs, reset is the boundary — the continuation captured by
|
||||
;; shift extends from the shift point back to (and including) this reset.
|
||||
;;
|
||||
;; reset is the "prompt" — it marks where the continuation stops.
|
||||
;;
|
||||
;; Semantics:
|
||||
;; (reset expr) where expr contains no shift
|
||||
;; → (eval expr env) ;; just evaluates normally
|
||||
;;
|
||||
;; (reset ... (shift k body) ...)
|
||||
;; → captures continuation, evaluates shift's body
|
||||
;; → the result of the shift body is the result of the reset
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-reset
|
||||
(fn ((args :as list) (env :as dict))
|
||||
;; Single argument: the body expression.
|
||||
;; Install a continuation delimiter, then evaluate body.
|
||||
;; The implementation is target-specific:
|
||||
;; - In Scheme: native reset/shift
|
||||
;; - In Haskell: Control.Monad.CC or delimited continuations library
|
||||
;; - In Python: coroutine/generator-based (see implementation notes)
|
||||
;; - In JavaScript: generator-based or CPS transform
|
||||
;; - In Rust: CPS transform at compile time
|
||||
(let ((body (first args)))
|
||||
(eval-with-delimiter body env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. shift — capture the continuation to the nearest reset
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (shift k body)
|
||||
;;
|
||||
;; Captures the continuation from this point back to the nearest enclosing
|
||||
;; reset and binds it to k. Then evaluates body in the current environment
|
||||
;; extended with k. The result of body becomes the result of the enclosing
|
||||
;; reset.
|
||||
;;
|
||||
;; k is a function of one argument. Calling (k value) resumes the captured
|
||||
;; computation with value standing in for the shift expression.
|
||||
;;
|
||||
;; The continuation k is composable: (k value) returns a value (the result
|
||||
;; of the reset body when resumed with value). This means k can be called
|
||||
;; multiple times, and its result can be used in further computation.
|
||||
;;
|
||||
;; Examples:
|
||||
;;
|
||||
;; ;; Basic: shift provides a value to the surrounding computation
|
||||
;; (reset (+ 1 (shift k (k 41))))
|
||||
;; ;; k = "add 1 to _", (k 41) → 42, reset returns 42
|
||||
;;
|
||||
;; ;; Abort: shift can discard the continuation entirely
|
||||
;; (reset (+ 1 (shift k "aborted")))
|
||||
;; ;; k is never called, reset returns "aborted"
|
||||
;;
|
||||
;; ;; Multiple invocations: k can be called more than once
|
||||
;; (reset (+ 1 (shift k (list (k 10) (k 20)))))
|
||||
;; ;; (k 10) → 11, (k 20) → 21, reset returns (11 21)
|
||||
;;
|
||||
;; ;; Stored for later: k can be saved and invoked outside reset
|
||||
;; (define saved nil)
|
||||
;; (reset (+ 1 (shift k (set! saved k) 0)))
|
||||
;; ;; reset returns 0, saved holds the continuation
|
||||
;; (saved 99) ;; → 100
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-shift
|
||||
(fn ((args :as list) (env :as dict))
|
||||
;; Two arguments: the continuation variable name, and the body.
|
||||
(let ((k-name (symbol-name (first args)))
|
||||
(body (second args)))
|
||||
;; Capture the current continuation up to the nearest reset.
|
||||
;; Bind it to k-name in the environment, then evaluate body.
|
||||
;; The result of body is returned to the reset.
|
||||
(capture-continuation k-name body env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Interaction with other features
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; TCO (trampoline):
|
||||
;; Continuations interact naturally with the trampoline. A shift inside
|
||||
;; a tail-call position captures the continuation including the pending
|
||||
;; return. The trampoline resolves thunks before the continuation is
|
||||
;; delimited.
|
||||
;;
|
||||
;; Macros:
|
||||
;; shift/reset are special forms, not macros. Macros expand before
|
||||
;; evaluation, so shift inside a macro-expanded form works correctly —
|
||||
;; it captures the continuation of the expanded code.
|
||||
;;
|
||||
;; Components:
|
||||
;; shift inside a component body captures the continuation of that
|
||||
;; component's render. The enclosing reset determines the delimiter.
|
||||
;; This is the foundation for suspendable rendering — a component can
|
||||
;; shift to suspend, and the server resumes it when data arrives.
|
||||
;;
|
||||
;; I/O primitives:
|
||||
;; I/O primitives execute at invocation time, in whatever context
|
||||
;; exists then. A continuation that captures a computation containing
|
||||
;; I/O will re-execute that I/O when invoked. If the I/O requires
|
||||
;; request context (e.g. current-user), invoking the continuation
|
||||
;; outside a request will fail — same as calling the I/O directly.
|
||||
;; This is consistent, not a restriction.
|
||||
;;
|
||||
;; In typed targets (Haskell, Rust), the type system can enforce that
|
||||
;; continuations containing I/O are only invoked in appropriate contexts.
|
||||
;; In dynamic targets (Python, JS), it fails at runtime.
|
||||
;;
|
||||
;; Lexical scope:
|
||||
;; Continuations capture the dynamic extent (what happens next) but
|
||||
;; close over the lexical environment at the point of capture. Variable
|
||||
;; bindings in the continuation refer to the same environment — mutations
|
||||
;; via set! are visible.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Implementation notes per target
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; The bootstrapper emits target-specific continuation machinery.
|
||||
;; The spec defines semantics; each target chooses representation.
|
||||
;;
|
||||
;; Scheme / Racket:
|
||||
;; Native shift/reset. No transformation needed. The bootstrapper
|
||||
;; emits (require racket/control) or equivalent.
|
||||
;;
|
||||
;; Haskell:
|
||||
;; Control.Monad.CC provides delimited continuations in the CC monad.
|
||||
;; Alternatively, the evaluator can be CPS-transformed at compile time.
|
||||
;; Continuations become first-class functions naturally.
|
||||
;;
|
||||
;; Python:
|
||||
;; Generator-based: reset creates a generator, shift yields from it.
|
||||
;; The trampoline loop drives the generator. Each yield is a shift
|
||||
;; point, and send() provides the resume value.
|
||||
;; Alternative: greenlet-based (stackful coroutines).
|
||||
;;
|
||||
;; JavaScript:
|
||||
;; Generator-based (function* / yield). Similar to Python.
|
||||
;; Alternative: CPS transform at bootstrap time — the bootstrapper
|
||||
;; rewrites the evaluator into continuation-passing style, making
|
||||
;; shift/reset explicit function arguments.
|
||||
;;
|
||||
;; Rust:
|
||||
;; CPS transform at compile time. Continuations become enum variants
|
||||
;; or boxed closures. The type system ensures continuations are used
|
||||
;; linearly if desired (affine types via ownership).
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Platform interface — what each target must provide
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (eval-with-delimiter expr env)
|
||||
;; Install a reset delimiter, evaluate expr, return result.
|
||||
;; If expr calls shift, the continuation is captured up to here.
|
||||
;;
|
||||
;; (capture-continuation k-name body env)
|
||||
;; Capture the current continuation up to the nearest delimiter.
|
||||
;; Bind it to k-name in env, evaluate body, return result to delimiter.
|
||||
;;
|
||||
;; (make-continuation fn)
|
||||
;; Wrap a native function as a continuation value.
|
||||
;;
|
||||
;; (continuation? x)
|
||||
;; Type predicate.
|
||||
;;
|
||||
;; Continuations must be callable via the standard function-call
|
||||
;; dispatch in eval-list (same path as lambda calls).
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
1184
spec/eval.sx
Normal file
1184
spec/eval.sx
Normal file
File diff suppressed because it is too large
Load Diff
262
spec/frames.sx
Normal file
262
spec/frames.sx
Normal file
@@ -0,0 +1,262 @@
|
||||
;; ==========================================================================
|
||||
;; frames.sx — CEK machine frame types
|
||||
;;
|
||||
;; Defines the continuation frame types used by the explicit CEK evaluator.
|
||||
;; Each frame represents a "what to do next" when a sub-evaluation completes.
|
||||
;;
|
||||
;; A CEK state is a dict:
|
||||
;; {:control expr — expression being evaluated (or nil in continue phase)
|
||||
;; :env env — current environment
|
||||
;; :kont list — continuation: list of frames (stack, head = top)
|
||||
;; :phase "eval"|"continue"
|
||||
;; :value any} — value produced (only in continue phase)
|
||||
;;
|
||||
;; Two-phase step function:
|
||||
;; step-eval: control is expression → dispatch → push frame + new control
|
||||
;; step-continue: value produced → pop frame → dispatch → new state
|
||||
;;
|
||||
;; Terminal state: phase = "continue" and kont is empty → value is final result.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. CEK State constructors
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define make-cek-state
|
||||
(fn (control env kont)
|
||||
{:control control :env env :kont kont :phase "eval" :value nil}))
|
||||
|
||||
(define make-cek-value
|
||||
(fn (value env kont)
|
||||
{:control nil :env env :kont kont :phase "continue" :value value}))
|
||||
|
||||
(define cek-terminal?
|
||||
(fn (state)
|
||||
(and (= (get state "phase") "continue")
|
||||
(empty? (get state "kont")))))
|
||||
|
||||
(define cek-control (fn (s) (get s "control")))
|
||||
(define cek-env (fn (s) (get s "env")))
|
||||
(define cek-kont (fn (s) (get s "kont")))
|
||||
(define cek-phase (fn (s) (get s "phase")))
|
||||
(define cek-value (fn (s) (get s "value")))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Frame constructors
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Each frame type is a dict with a "type" key and frame-specific data.
|
||||
|
||||
;; IfFrame: waiting for condition value
|
||||
;; After condition evaluates, choose then or else branch
|
||||
(define make-if-frame
|
||||
(fn (then-expr else-expr env)
|
||||
{:type "if" :then then-expr :else else-expr :env env}))
|
||||
|
||||
;; WhenFrame: waiting for condition value
|
||||
;; If truthy, evaluate body exprs sequentially
|
||||
(define make-when-frame
|
||||
(fn (body-exprs env)
|
||||
{:type "when" :body body-exprs :env env}))
|
||||
|
||||
;; BeginFrame: sequential evaluation
|
||||
;; Remaining expressions to evaluate after current one
|
||||
(define make-begin-frame
|
||||
(fn (remaining env)
|
||||
{:type "begin" :remaining remaining :env env}))
|
||||
|
||||
;; LetFrame: binding evaluation in progress
|
||||
;; name = current binding name, remaining = remaining (name val) pairs
|
||||
;; body = body expressions to evaluate after all bindings
|
||||
(define make-let-frame
|
||||
(fn (name remaining body local)
|
||||
{:type "let" :name name :remaining remaining :body body :env local}))
|
||||
|
||||
;; DefineFrame: waiting for value to bind
|
||||
(define make-define-frame
|
||||
(fn (name env has-effects effect-list)
|
||||
{:type "define" :name name :env env
|
||||
:has-effects has-effects :effect-list effect-list}))
|
||||
|
||||
;; SetFrame: waiting for value to assign
|
||||
(define make-set-frame
|
||||
(fn (name env)
|
||||
{:type "set" :name name :env env}))
|
||||
|
||||
;; ArgFrame: evaluating function arguments
|
||||
;; f = function value (already evaluated), evaled = already evaluated args
|
||||
;; remaining = remaining arg expressions
|
||||
(define make-arg-frame
|
||||
(fn (f evaled remaining env raw-args)
|
||||
{:type "arg" :f f :evaled evaled :remaining remaining :env env
|
||||
:raw-args raw-args}))
|
||||
|
||||
;; CallFrame: about to call with fully evaluated args
|
||||
(define make-call-frame
|
||||
(fn (f args env)
|
||||
{:type "call" :f f :args args :env env}))
|
||||
|
||||
;; CondFrame: evaluating cond clauses
|
||||
(define make-cond-frame
|
||||
(fn (remaining env scheme?)
|
||||
{:type "cond" :remaining remaining :env env :scheme scheme?}))
|
||||
|
||||
;; CaseFrame: evaluating case clauses
|
||||
(define make-case-frame
|
||||
(fn (match-val remaining env)
|
||||
{:type "case" :match-val match-val :remaining remaining :env env}))
|
||||
|
||||
;; ThreadFirstFrame: pipe threading
|
||||
(define make-thread-frame
|
||||
(fn (remaining env)
|
||||
{:type "thread" :remaining remaining :env env}))
|
||||
|
||||
;; MapFrame: higher-order map/map-indexed in progress
|
||||
(define make-map-frame
|
||||
(fn (f remaining results env)
|
||||
{:type "map" :f f :remaining remaining :results results :env env :indexed false}))
|
||||
|
||||
(define make-map-indexed-frame
|
||||
(fn (f remaining results env)
|
||||
{:type "map" :f f :remaining remaining :results results :env env :indexed true}))
|
||||
|
||||
;; FilterFrame: higher-order filter in progress
|
||||
(define make-filter-frame
|
||||
(fn (f remaining results current-item env)
|
||||
{:type "filter" :f f :remaining remaining :results results
|
||||
:current-item current-item :env env}))
|
||||
|
||||
;; ReduceFrame: higher-order reduce in progress
|
||||
(define make-reduce-frame
|
||||
(fn (f remaining env)
|
||||
{:type "reduce" :f f :remaining remaining :env env}))
|
||||
|
||||
;; ForEachFrame: higher-order for-each in progress
|
||||
(define make-for-each-frame
|
||||
(fn (f remaining env)
|
||||
{:type "for-each" :f f :remaining remaining :env env}))
|
||||
|
||||
;; SomeFrame: higher-order some (short-circuit on first truthy)
|
||||
(define make-some-frame
|
||||
(fn (f remaining env)
|
||||
{:type "some" :f f :remaining remaining :env env}))
|
||||
|
||||
;; EveryFrame: higher-order every? (short-circuit on first falsy)
|
||||
(define make-every-frame
|
||||
(fn (f remaining env)
|
||||
{:type "every" :f f :remaining remaining :env env}))
|
||||
|
||||
;; ScopeFrame: scope-pop! when frame pops
|
||||
(define make-scope-frame
|
||||
(fn (name remaining env)
|
||||
{:type "scope" :name name :remaining remaining :env env}))
|
||||
|
||||
;; ResetFrame: delimiter for shift/reset continuations
|
||||
(define make-reset-frame
|
||||
(fn (env)
|
||||
{:type "reset" :env env}))
|
||||
|
||||
;; DictFrame: evaluating dict values
|
||||
(define make-dict-frame
|
||||
(fn (remaining results env)
|
||||
{:type "dict" :remaining remaining :results results :env env}))
|
||||
|
||||
;; AndFrame: short-circuit and
|
||||
(define make-and-frame
|
||||
(fn (remaining env)
|
||||
{:type "and" :remaining remaining :env env}))
|
||||
|
||||
;; OrFrame: short-circuit or
|
||||
(define make-or-frame
|
||||
(fn (remaining env)
|
||||
{:type "or" :remaining remaining :env env}))
|
||||
|
||||
;; QuasiquoteFrame (not a real frame — QQ is handled specially)
|
||||
|
||||
;; DynamicWindFrame: phases of dynamic-wind
|
||||
(define make-dynamic-wind-frame
|
||||
(fn (phase body-thunk after-thunk env)
|
||||
{:type "dynamic-wind" :phase phase
|
||||
:body-thunk body-thunk :after-thunk after-thunk :env env}))
|
||||
|
||||
;; ReactiveResetFrame: delimiter for reactive deref-as-shift
|
||||
;; Carries an update-fn that gets called with new values on re-render.
|
||||
(define make-reactive-reset-frame
|
||||
(fn (env update-fn first-render?)
|
||||
{:type "reactive-reset" :env env :update-fn update-fn
|
||||
:first-render first-render?}))
|
||||
|
||||
;; DerefFrame: awaiting evaluation of deref's argument
|
||||
(define make-deref-frame
|
||||
(fn (env)
|
||||
{:type "deref" :env env}))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Frame accessors
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define frame-type (fn (f) (get f "type")))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Continuation operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define kont-push
|
||||
(fn (frame kont) (cons frame kont)))
|
||||
|
||||
(define kont-top
|
||||
(fn (kont) (first kont)))
|
||||
|
||||
(define kont-pop
|
||||
(fn (kont) (rest kont)))
|
||||
|
||||
(define kont-empty?
|
||||
(fn (kont) (empty? kont)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. CEK shift/reset support
|
||||
;; --------------------------------------------------------------------------
|
||||
;; shift captures all frames up to the nearest ResetFrame.
|
||||
;; reset pushes a ResetFrame.
|
||||
|
||||
(define kont-capture-to-reset
|
||||
(fn (kont)
|
||||
;; Returns (captured-frames remaining-kont).
|
||||
;; captured-frames: frames from top up to (not including) ResetFrame.
|
||||
;; remaining-kont: frames after ResetFrame.
|
||||
;; Stops at either "reset" or "reactive-reset" frames.
|
||||
(define scan
|
||||
(fn (k captured)
|
||||
(if (empty? k)
|
||||
(error "shift without enclosing reset")
|
||||
(let ((frame (first k)))
|
||||
(if (or (= (frame-type frame) "reset")
|
||||
(= (frame-type frame) "reactive-reset"))
|
||||
(list captured (rest k))
|
||||
(scan (rest k) (append captured (list frame))))))))
|
||||
(scan kont (list))))
|
||||
|
||||
;; Check if a ReactiveResetFrame exists anywhere in the continuation
|
||||
(define has-reactive-reset-frame?
|
||||
(fn (kont)
|
||||
(if (empty? kont) false
|
||||
(if (= (frame-type (first kont)) "reactive-reset") true
|
||||
(has-reactive-reset-frame? (rest kont))))))
|
||||
|
||||
;; Capture frames up to nearest ReactiveResetFrame.
|
||||
;; Returns (captured-frames, reset-frame, remaining-kont).
|
||||
(define kont-capture-to-reactive-reset
|
||||
(fn (kont)
|
||||
(define scan
|
||||
(fn (k captured)
|
||||
(if (empty? k)
|
||||
(error "reactive deref without enclosing reactive-reset")
|
||||
(let ((frame (first k)))
|
||||
(if (= (frame-type frame) "reactive-reset")
|
||||
(list captured frame (rest k))
|
||||
(scan (rest k) (append captured (list frame))))))))
|
||||
(scan kont (list))))
|
||||
418
spec/parser.sx
Normal file
418
spec/parser.sx
Normal file
@@ -0,0 +1,418 @@
|
||||
;; ==========================================================================
|
||||
;; parser.sx — Reference SX parser specification
|
||||
;;
|
||||
;; Defines how SX source text is tokenized and parsed into AST.
|
||||
;; The parser is intentionally simple — s-expressions need minimal parsing.
|
||||
;;
|
||||
;; Single-pass recursive descent: reads source text directly into AST,
|
||||
;; no separate tokenization phase. All mutable cursor state lives inside
|
||||
;; the parse closure.
|
||||
;;
|
||||
;; Grammar:
|
||||
;; program → expr*
|
||||
;; expr → atom | list | vector | map | quote-sugar
|
||||
;; list → '(' expr* ')'
|
||||
;; vector → '[' expr* ']' (sugar for list)
|
||||
;; map → '{' (key expr)* '}'
|
||||
;; atom → string | number | keyword | symbol | boolean | nil
|
||||
;; string → '"' (char | escape)* '"'
|
||||
;; number → '-'? digit+ ('.' digit+)? ([eE] [+-]? digit+)?
|
||||
;; keyword → ':' ident
|
||||
;; symbol → ident
|
||||
;; boolean → 'true' | 'false'
|
||||
;; nil → 'nil'
|
||||
;; ident → ident-start ident-char*
|
||||
;; comment → ';' to end of line (discarded)
|
||||
;;
|
||||
;; Quote sugar:
|
||||
;; 'expr → (quote expr)
|
||||
;; `expr → (quasiquote expr)
|
||||
;; ,expr → (unquote expr)
|
||||
;; ,@expr → (splice-unquote expr)
|
||||
;;
|
||||
;; Reader macros:
|
||||
;; #;expr → datum comment (read and discard expr)
|
||||
;; #|raw chars| → raw string literal (no escape processing)
|
||||
;; #'expr → (quote expr)
|
||||
;; #name expr → extensible dispatch (calls registered handler)
|
||||
;;
|
||||
;; Platform interface (each target implements natively):
|
||||
;; (ident-start? ch) → boolean
|
||||
;; (ident-char? ch) → boolean
|
||||
;; (make-symbol name) → Symbol value
|
||||
;; (make-keyword name) → Keyword value
|
||||
;; (escape-string s) → string with " and \ escaped for serialization
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Parser — single-pass recursive descent
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns a list of top-level AST expressions.
|
||||
|
||||
(define sx-parse :effects []
|
||||
(fn ((source :as string))
|
||||
(let ((pos 0)
|
||||
(len-src (len source)))
|
||||
|
||||
;; -- Cursor helpers (closure over pos, source, len-src) --
|
||||
|
||||
(define skip-comment :effects []
|
||||
(fn ()
|
||||
(when (and (< pos len-src) (not (= (nth source pos) "\n")))
|
||||
(set! pos (inc pos))
|
||||
(skip-comment))))
|
||||
|
||||
(define skip-ws :effects []
|
||||
(fn ()
|
||||
(when (< pos len-src)
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
;; Whitespace
|
||||
(or (= ch " ") (= ch "\t") (= ch "\n") (= ch "\r"))
|
||||
(do (set! pos (inc pos)) (skip-ws))
|
||||
;; Comment — skip to end of line
|
||||
(= ch ";")
|
||||
(do (set! pos (inc pos))
|
||||
(skip-comment)
|
||||
(skip-ws))
|
||||
;; Not whitespace or comment — stop
|
||||
:else nil)))))
|
||||
|
||||
;; -- Atom readers --
|
||||
|
||||
(define hex-digit-value :effects []
|
||||
(fn (ch) (index-of "0123456789abcdef" (lower ch))))
|
||||
|
||||
(define read-string :effects []
|
||||
(fn ()
|
||||
(set! pos (inc pos)) ;; skip opening "
|
||||
(let ((buf ""))
|
||||
(define read-str-loop :effects []
|
||||
(fn ()
|
||||
(if (>= pos len-src)
|
||||
(error "Unterminated string")
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
(= ch "\"")
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(= ch "\\")
|
||||
(do (set! pos (inc pos))
|
||||
(let ((esc (nth source pos)))
|
||||
(if (= esc "u")
|
||||
;; Unicode escape: \uXXXX → char
|
||||
(do (set! pos (inc pos))
|
||||
(let ((d0 (hex-digit-value (nth source pos)))
|
||||
(_ (set! pos (inc pos)))
|
||||
(d1 (hex-digit-value (nth source pos)))
|
||||
(_ (set! pos (inc pos)))
|
||||
(d2 (hex-digit-value (nth source pos)))
|
||||
(_ (set! pos (inc pos)))
|
||||
(d3 (hex-digit-value (nth source pos)))
|
||||
(_ (set! pos (inc pos))))
|
||||
(set! buf (str buf (char-from-code
|
||||
(+ (* d0 4096) (* d1 256) (* d2 16) d3))))
|
||||
(read-str-loop)))
|
||||
;; Standard escapes: \n \t \r or literal
|
||||
(do (set! buf (str buf
|
||||
(cond
|
||||
(= esc "n") "\n"
|
||||
(= esc "t") "\t"
|
||||
(= esc "r") "\r"
|
||||
:else esc)))
|
||||
(set! pos (inc pos))
|
||||
(read-str-loop)))))
|
||||
:else
|
||||
(do (set! buf (str buf ch))
|
||||
(set! pos (inc pos))
|
||||
(read-str-loop)))))))
|
||||
(read-str-loop)
|
||||
buf)))
|
||||
|
||||
(define read-ident :effects []
|
||||
(fn ()
|
||||
(let ((start pos))
|
||||
(define read-ident-loop :effects []
|
||||
(fn ()
|
||||
(when (and (< pos len-src)
|
||||
(ident-char? (nth source pos)))
|
||||
(set! pos (inc pos))
|
||||
(read-ident-loop))))
|
||||
(read-ident-loop)
|
||||
(slice source start pos))))
|
||||
|
||||
(define read-keyword :effects []
|
||||
(fn ()
|
||||
(set! pos (inc pos)) ;; skip :
|
||||
(make-keyword (read-ident))))
|
||||
|
||||
(define read-number :effects []
|
||||
(fn ()
|
||||
(let ((start pos))
|
||||
;; Optional leading minus
|
||||
(when (and (< pos len-src) (= (nth source pos) "-"))
|
||||
(set! pos (inc pos)))
|
||||
;; Integer digits
|
||||
(define read-digits :effects []
|
||||
(fn ()
|
||||
(when (and (< pos len-src)
|
||||
(let ((c (nth source pos)))
|
||||
(and (>= c "0") (<= c "9"))))
|
||||
(set! pos (inc pos))
|
||||
(read-digits))))
|
||||
(read-digits)
|
||||
;; Decimal part
|
||||
(when (and (< pos len-src) (= (nth source pos) "."))
|
||||
(set! pos (inc pos))
|
||||
(read-digits))
|
||||
;; Exponent
|
||||
(when (and (< pos len-src)
|
||||
(or (= (nth source pos) "e")
|
||||
(= (nth source pos) "E")))
|
||||
(set! pos (inc pos))
|
||||
(when (and (< pos len-src)
|
||||
(or (= (nth source pos) "+")
|
||||
(= (nth source pos) "-")))
|
||||
(set! pos (inc pos)))
|
||||
(read-digits))
|
||||
(parse-number (slice source start pos)))))
|
||||
|
||||
(define read-symbol :effects []
|
||||
(fn ()
|
||||
(let ((name (read-ident)))
|
||||
(cond
|
||||
(= name "true") true
|
||||
(= name "false") false
|
||||
(= name "nil") nil
|
||||
:else (make-symbol name)))))
|
||||
|
||||
;; -- Composite readers --
|
||||
|
||||
(define read-list :effects []
|
||||
(fn ((close-ch :as string))
|
||||
(let ((items (list)))
|
||||
(define read-list-loop :effects []
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(if (>= pos len-src)
|
||||
(error "Unterminated list")
|
||||
(if (= (nth source pos) close-ch)
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(do (append! items (read-expr))
|
||||
(read-list-loop))))))
|
||||
(read-list-loop)
|
||||
items)))
|
||||
|
||||
(define read-map :effects []
|
||||
(fn ()
|
||||
(let ((result (dict)))
|
||||
(define read-map-loop :effects []
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(if (>= pos len-src)
|
||||
(error "Unterminated map")
|
||||
(if (= (nth source pos) "}")
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(let ((key-expr (read-expr))
|
||||
(key-str (if (= (type-of key-expr) "keyword")
|
||||
(keyword-name key-expr)
|
||||
(str key-expr)))
|
||||
(val-expr (read-expr)))
|
||||
(dict-set! result key-str val-expr)
|
||||
(read-map-loop))))))
|
||||
(read-map-loop)
|
||||
result)))
|
||||
|
||||
;; -- Raw string reader (for #|...|) --
|
||||
|
||||
(define read-raw-string :effects []
|
||||
(fn ()
|
||||
(let ((buf ""))
|
||||
(define raw-loop :effects []
|
||||
(fn ()
|
||||
(if (>= pos len-src)
|
||||
(error "Unterminated raw string")
|
||||
(let ((ch (nth source pos)))
|
||||
(if (= ch "|")
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(do (set! buf (str buf ch))
|
||||
(set! pos (inc pos))
|
||||
(raw-loop)))))))
|
||||
(raw-loop)
|
||||
buf)))
|
||||
|
||||
;; -- Main expression reader --
|
||||
|
||||
(define read-expr :effects []
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(if (>= pos len-src)
|
||||
(error "Unexpected end of input")
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
;; Lists
|
||||
(= ch "(")
|
||||
(do (set! pos (inc pos)) (read-list ")"))
|
||||
(= ch "[")
|
||||
(do (set! pos (inc pos)) (read-list "]"))
|
||||
|
||||
;; Map
|
||||
(= ch "{")
|
||||
(do (set! pos (inc pos)) (read-map))
|
||||
|
||||
;; String
|
||||
(= ch "\"")
|
||||
(read-string)
|
||||
|
||||
;; Keyword
|
||||
(= ch ":")
|
||||
(read-keyword)
|
||||
|
||||
;; Quote sugar
|
||||
(= ch "'")
|
||||
(do (set! pos (inc pos))
|
||||
(list (make-symbol "quote") (read-expr)))
|
||||
|
||||
;; Quasiquote sugar
|
||||
(= ch "`")
|
||||
(do (set! pos (inc pos))
|
||||
(list (make-symbol "quasiquote") (read-expr)))
|
||||
|
||||
;; Unquote / splice-unquote
|
||||
(= ch ",")
|
||||
(do (set! pos (inc pos))
|
||||
(if (and (< pos len-src) (= (nth source pos) "@"))
|
||||
(do (set! pos (inc pos))
|
||||
(list (make-symbol "splice-unquote") (read-expr)))
|
||||
(list (make-symbol "unquote") (read-expr))))
|
||||
|
||||
;; Reader macros: #
|
||||
(= ch "#")
|
||||
(do (set! pos (inc pos))
|
||||
(if (>= pos len-src)
|
||||
(error "Unexpected end of input after #")
|
||||
(let ((dispatch-ch (nth source pos)))
|
||||
(cond
|
||||
;; #; — datum comment: read and discard next expr
|
||||
(= dispatch-ch ";")
|
||||
(do (set! pos (inc pos))
|
||||
(read-expr) ;; read and discard
|
||||
(read-expr)) ;; return the NEXT expr
|
||||
|
||||
;; #| — raw string
|
||||
(= dispatch-ch "|")
|
||||
(do (set! pos (inc pos))
|
||||
(read-raw-string))
|
||||
|
||||
;; #' — quote shorthand
|
||||
(= dispatch-ch "'")
|
||||
(do (set! pos (inc pos))
|
||||
(list (make-symbol "quote") (read-expr)))
|
||||
|
||||
;; #name — extensible dispatch
|
||||
(ident-start? dispatch-ch)
|
||||
(let ((macro-name (read-ident)))
|
||||
(let ((handler (reader-macro-get macro-name)))
|
||||
(if handler
|
||||
(handler (read-expr))
|
||||
(error (str "Unknown reader macro: #" macro-name)))))
|
||||
|
||||
:else
|
||||
(error (str "Unknown reader macro: #" dispatch-ch))))))
|
||||
|
||||
;; Number (or negative number)
|
||||
(or (and (>= ch "0") (<= ch "9"))
|
||||
(and (= ch "-")
|
||||
(< (inc pos) len-src)
|
||||
(let ((next-ch (nth source (inc pos))))
|
||||
(and (>= next-ch "0") (<= next-ch "9")))))
|
||||
(read-number)
|
||||
|
||||
;; Ellipsis (... as a symbol)
|
||||
(and (= ch ".")
|
||||
(< (+ pos 2) len-src)
|
||||
(= (nth source (+ pos 1)) ".")
|
||||
(= (nth source (+ pos 2)) "."))
|
||||
(do (set! pos (+ pos 3))
|
||||
(make-symbol "..."))
|
||||
|
||||
;; Symbol (must be ident-start char)
|
||||
(ident-start? ch)
|
||||
(read-symbol)
|
||||
|
||||
;; Unexpected
|
||||
:else
|
||||
(error (str "Unexpected character: " ch)))))))
|
||||
|
||||
;; -- Entry point: parse all top-level expressions --
|
||||
(let ((exprs (list)))
|
||||
(define parse-loop :effects []
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(when (< pos len-src)
|
||||
(append! exprs (read-expr))
|
||||
(parse-loop))))
|
||||
(parse-loop)
|
||||
exprs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Serializer — AST → SX source text
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sx-serialize :effects []
|
||||
(fn (val)
|
||||
(case (type-of val)
|
||||
"nil" "nil"
|
||||
"boolean" (if val "true" "false")
|
||||
"number" (str val)
|
||||
"string" (str "\"" (escape-string val) "\"")
|
||||
"symbol" (symbol-name val)
|
||||
"keyword" (str ":" (keyword-name val))
|
||||
"list" (str "(" (join " " (map sx-serialize val)) ")")
|
||||
"dict" (sx-serialize-dict val)
|
||||
"sx-expr" (sx-expr-source val)
|
||||
"spread" (str "(make-spread " (sx-serialize-dict (spread-attrs val)) ")")
|
||||
:else (str val))))
|
||||
|
||||
|
||||
(define sx-serialize-dict :effects []
|
||||
(fn ((d :as dict))
|
||||
(str "{"
|
||||
(join " "
|
||||
(reduce
|
||||
(fn ((acc :as list) (key :as string))
|
||||
(concat acc (list (str ":" key) (sx-serialize (dict-get d key)))))
|
||||
(list)
|
||||
(keys d)))
|
||||
"}")))
|
||||
|
||||
|
||||
;; Alias: adapters use (serialize val) — canonicalize to sx-serialize
|
||||
(define serialize sx-serialize)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform parser interface
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Character classification (implemented natively per target):
|
||||
;; (ident-start? ch) → boolean
|
||||
;; True for: a-z A-Z _ ~ * + - > < = / ! ? &
|
||||
;;
|
||||
;; (ident-char? ch) → boolean
|
||||
;; True for: ident-start chars plus: 0-9 . : / # ,
|
||||
;;
|
||||
;; Constructors (provided by the SX runtime):
|
||||
;; (make-symbol name) → Symbol value
|
||||
;; (make-keyword name) → Keyword value
|
||||
;; (parse-number s) → number (int or float from string)
|
||||
;;
|
||||
;; String utilities:
|
||||
;; (escape-string s) → string with " and \ escaped
|
||||
;; (sx-expr-source e) → unwrap SxExpr to its source string
|
||||
;;
|
||||
;; Reader macro registry:
|
||||
;; (reader-macro-get name) → handler fn or nil
|
||||
;; (reader-macro-set! name handler) → register a reader macro
|
||||
;; --------------------------------------------------------------------------
|
||||
607
spec/primitives.sx
Normal file
607
spec/primitives.sx
Normal file
@@ -0,0 +1,607 @@
|
||||
;; ==========================================================================
|
||||
;; primitives.sx — Specification of all SX built-in pure functions
|
||||
;;
|
||||
;; Each entry declares: name, parameter signature, and semantics.
|
||||
;; Bootstrap compilers implement these natively per target.
|
||||
;;
|
||||
;; This file is a SPECIFICATION, not executable code. The define-primitive
|
||||
;; form is a declarative macro that bootstrap compilers consume to generate
|
||||
;; native primitive registrations.
|
||||
;;
|
||||
;; Format:
|
||||
;; (define-primitive "name"
|
||||
;; :params (param1 param2 &rest rest)
|
||||
;; :returns "type"
|
||||
;; :doc "description"
|
||||
;; :body (reference-implementation ...))
|
||||
;;
|
||||
;; Typed params use (name :as type) syntax:
|
||||
;; (define-primitive "+"
|
||||
;; :params (&rest (args :as number))
|
||||
;; :returns "number"
|
||||
;; :doc "Sum all arguments.")
|
||||
;;
|
||||
;; Untyped params default to `any`. Typed params enable the gradual
|
||||
;; type checker (types.sx) to catch mistyped primitive calls.
|
||||
;;
|
||||
;; The :body is optional — when provided, it gives a reference
|
||||
;; implementation in SX that bootstrap compilers MAY use for testing
|
||||
;; or as a fallback. Most targets will implement natively for performance.
|
||||
;;
|
||||
;; Modules: (define-module :name) scopes subsequent define-primitive
|
||||
;; entries until the next define-module. Bootstrappers use this to
|
||||
;; selectively include primitive groups.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Core — Arithmetic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.arithmetic)
|
||||
|
||||
(define-primitive "+"
|
||||
:params (&rest (args :as number))
|
||||
:returns "number"
|
||||
:doc "Sum all arguments."
|
||||
:body (reduce (fn (a b) (native-add a b)) 0 args))
|
||||
|
||||
(define-primitive "-"
|
||||
:params ((a :as number) &rest (b :as number))
|
||||
:returns "number"
|
||||
:doc "Subtract. Unary: negate. Binary: a - b."
|
||||
:body (if (empty? b) (native-neg a) (native-sub a (first b))))
|
||||
|
||||
(define-primitive "*"
|
||||
:params (&rest (args :as number))
|
||||
:returns "number"
|
||||
:doc "Multiply all arguments."
|
||||
:body (reduce (fn (a b) (native-mul a b)) 1 args))
|
||||
|
||||
(define-primitive "/"
|
||||
:params ((a :as number) (b :as number))
|
||||
:returns "number"
|
||||
:doc "Divide a by b."
|
||||
:body (native-div a b))
|
||||
|
||||
(define-primitive "mod"
|
||||
:params ((a :as number) (b :as number))
|
||||
:returns "number"
|
||||
:doc "Modulo a % b."
|
||||
:body (native-mod a b))
|
||||
|
||||
(define-primitive "random-int"
|
||||
:params ((low :as number) (high :as number))
|
||||
:returns "number"
|
||||
:doc "Random integer in [low, high] inclusive."
|
||||
:body (native-random-int low high))
|
||||
|
||||
(define-primitive "json-encode"
|
||||
:params (value)
|
||||
:returns "string"
|
||||
:doc "Encode value as JSON string with indentation.")
|
||||
|
||||
(define-primitive "sqrt"
|
||||
:params ((x :as number))
|
||||
:returns "number"
|
||||
:doc "Square root.")
|
||||
|
||||
(define-primitive "pow"
|
||||
:params ((x :as number) (n :as number))
|
||||
:returns "number"
|
||||
:doc "x raised to power n.")
|
||||
|
||||
(define-primitive "abs"
|
||||
:params ((x :as number))
|
||||
:returns "number"
|
||||
:doc "Absolute value.")
|
||||
|
||||
(define-primitive "floor"
|
||||
:params ((x :as number))
|
||||
:returns "number"
|
||||
:doc "Floor to integer.")
|
||||
|
||||
(define-primitive "ceil"
|
||||
:params ((x :as number))
|
||||
:returns "number"
|
||||
:doc "Ceiling to integer.")
|
||||
|
||||
(define-primitive "round"
|
||||
:params ((x :as number) &rest (ndigits :as number))
|
||||
:returns "number"
|
||||
:doc "Round to ndigits decimal places (default 0).")
|
||||
|
||||
(define-primitive "min"
|
||||
:params (&rest (args :as number))
|
||||
:returns "number"
|
||||
:doc "Minimum. Single list arg or variadic.")
|
||||
|
||||
(define-primitive "max"
|
||||
:params (&rest (args :as number))
|
||||
:returns "number"
|
||||
:doc "Maximum. Single list arg or variadic.")
|
||||
|
||||
(define-primitive "clamp"
|
||||
:params ((x :as number) (lo :as number) (hi :as number))
|
||||
:returns "number"
|
||||
:doc "Clamp x to range [lo, hi]."
|
||||
:body (max lo (min hi x)))
|
||||
|
||||
(define-primitive "inc"
|
||||
:params ((n :as number))
|
||||
:returns "number"
|
||||
:doc "Increment by 1."
|
||||
:body (+ n 1))
|
||||
|
||||
(define-primitive "dec"
|
||||
:params ((n :as number))
|
||||
:returns "number"
|
||||
:doc "Decrement by 1."
|
||||
:body (- n 1))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Core — Comparison
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.comparison)
|
||||
|
||||
(define-primitive "="
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Deep structural equality. Alias for equal?.")
|
||||
|
||||
(define-primitive "!="
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Inequality."
|
||||
:body (not (= a b)))
|
||||
|
||||
(define-primitive "eq?"
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Identity equality. True only if a and b are the exact same object.
|
||||
For immutable atoms (numbers, strings, booleans, nil) this may or
|
||||
may not match — use eqv? for reliable atom comparison.")
|
||||
|
||||
(define-primitive "eqv?"
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Equivalent value for atoms, identity for compound objects.
|
||||
Returns true for identical objects (eq?), and also for numbers,
|
||||
strings, booleans, and nil with the same value. For lists, dicts,
|
||||
lambdas, and components, only true if same identity.")
|
||||
|
||||
(define-primitive "equal?"
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Deep structural equality. Recursively compares lists and dicts.
|
||||
Same semantics as = but explicit Scheme name.")
|
||||
|
||||
(define-primitive "<"
|
||||
:params ((a :as number) (b :as number))
|
||||
:returns "boolean"
|
||||
:doc "Less than.")
|
||||
|
||||
(define-primitive ">"
|
||||
:params ((a :as number) (b :as number))
|
||||
:returns "boolean"
|
||||
:doc "Greater than.")
|
||||
|
||||
(define-primitive "<="
|
||||
:params ((a :as number) (b :as number))
|
||||
:returns "boolean"
|
||||
:doc "Less than or equal.")
|
||||
|
||||
(define-primitive ">="
|
||||
:params ((a :as number) (b :as number))
|
||||
:returns "boolean"
|
||||
:doc "Greater than or equal.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Core — Predicates
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.predicates)
|
||||
|
||||
(define-primitive "odd?"
|
||||
:params ((n :as number))
|
||||
:returns "boolean"
|
||||
:doc "True if n is odd."
|
||||
:body (= (mod n 2) 1))
|
||||
|
||||
(define-primitive "even?"
|
||||
:params ((n :as number))
|
||||
:returns "boolean"
|
||||
:doc "True if n is even."
|
||||
:body (= (mod n 2) 0))
|
||||
|
||||
(define-primitive "zero?"
|
||||
:params ((n :as number))
|
||||
:returns "boolean"
|
||||
:doc "True if n is zero."
|
||||
:body (= n 0))
|
||||
|
||||
(define-primitive "nil?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is nil/null/None.")
|
||||
|
||||
(define-primitive "boolean?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a boolean (true or false). Must be checked before
|
||||
number? on platforms where booleans are numeric subtypes.")
|
||||
|
||||
(define-primitive "number?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a number (int or float). Excludes booleans.")
|
||||
|
||||
(define-primitive "string?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a string.")
|
||||
|
||||
(define-primitive "list?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a list/array.")
|
||||
|
||||
(define-primitive "dict?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a dict/map.")
|
||||
|
||||
(define-primitive "continuation?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a captured continuation.")
|
||||
|
||||
(define-primitive "empty?"
|
||||
:params (coll)
|
||||
:returns "boolean"
|
||||
:doc "True if coll is nil or has length 0.")
|
||||
|
||||
(define-primitive "contains?"
|
||||
:params (coll key)
|
||||
:returns "boolean"
|
||||
:doc "True if coll contains key. Strings: substring check. Dicts: key check. Lists: membership.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Core — Logic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.logic)
|
||||
|
||||
(define-primitive "not"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "Logical negation. Note: and/or are special forms (short-circuit).")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Core — Strings
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.strings)
|
||||
|
||||
(define-primitive "str"
|
||||
:params (&rest args)
|
||||
:returns "string"
|
||||
:doc "Concatenate all args as strings. nil → empty string, bool → true/false.")
|
||||
|
||||
(define-primitive "concat"
|
||||
:params (&rest (colls :as list))
|
||||
:returns "list"
|
||||
:doc "Concatenate multiple lists into one. Skips nil values.")
|
||||
|
||||
(define-primitive "upper"
|
||||
:params ((s :as string))
|
||||
:returns "string"
|
||||
:doc "Uppercase string.")
|
||||
|
||||
(define-primitive "upcase"
|
||||
:params ((s :as string))
|
||||
:returns "string"
|
||||
:doc "Alias for upper. Uppercase string.")
|
||||
|
||||
(define-primitive "lower"
|
||||
:params ((s :as string))
|
||||
:returns "string"
|
||||
:doc "Lowercase string.")
|
||||
|
||||
(define-primitive "downcase"
|
||||
:params ((s :as string))
|
||||
:returns "string"
|
||||
:doc "Alias for lower. Lowercase string.")
|
||||
|
||||
(define-primitive "string-length"
|
||||
:params ((s :as string))
|
||||
:returns "number"
|
||||
:doc "Length of string in characters.")
|
||||
|
||||
(define-primitive "char-from-code"
|
||||
:params ((n :as number))
|
||||
:returns "string"
|
||||
:doc "Convert Unicode code point to single-character string.")
|
||||
|
||||
(define-primitive "substring"
|
||||
:params ((s :as string) (start :as number) (end :as number))
|
||||
:returns "string"
|
||||
:doc "Extract substring from start (inclusive) to end (exclusive).")
|
||||
|
||||
(define-primitive "string-contains?"
|
||||
:params ((s :as string) (needle :as string))
|
||||
:returns "boolean"
|
||||
:doc "True if string s contains substring needle.")
|
||||
|
||||
(define-primitive "trim"
|
||||
:params ((s :as string))
|
||||
:returns "string"
|
||||
:doc "Strip leading/trailing whitespace.")
|
||||
|
||||
(define-primitive "split"
|
||||
:params ((s :as string) &rest (sep :as string))
|
||||
:returns "list"
|
||||
:doc "Split string by separator (default space).")
|
||||
|
||||
(define-primitive "join"
|
||||
:params ((sep :as string) (coll :as list))
|
||||
:returns "string"
|
||||
:doc "Join collection items with separator string.")
|
||||
|
||||
(define-primitive "replace"
|
||||
:params ((s :as string) (old :as string) (new :as string))
|
||||
:returns "string"
|
||||
:doc "Replace all occurrences of old with new in s.")
|
||||
|
||||
(define-primitive "slice"
|
||||
:params (coll (start :as number) &rest (end :as number))
|
||||
:returns "any"
|
||||
:doc "Slice a string or list from start to end (exclusive). End is optional.")
|
||||
|
||||
(define-primitive "index-of"
|
||||
:params ((s :as string) (needle :as string) &rest (from :as number))
|
||||
:returns "number"
|
||||
:doc "Index of first occurrence of needle in s, or -1 if not found. Optional start index.")
|
||||
|
||||
(define-primitive "starts-with?"
|
||||
:params ((s :as string) (prefix :as string))
|
||||
:returns "boolean"
|
||||
:doc "True if string s starts with prefix.")
|
||||
|
||||
(define-primitive "ends-with?"
|
||||
:params ((s :as string) (suffix :as string))
|
||||
:returns "boolean"
|
||||
:doc "True if string s ends with suffix.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Core — Collections
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.collections)
|
||||
|
||||
(define-primitive "list"
|
||||
:params (&rest args)
|
||||
:returns "list"
|
||||
:doc "Create a list from arguments.")
|
||||
|
||||
(define-primitive "dict"
|
||||
:params (&rest pairs)
|
||||
:returns "dict"
|
||||
:doc "Create a dict from key/value pairs: (dict :a 1 :b 2).")
|
||||
|
||||
(define-primitive "range"
|
||||
:params ((start :as number) (end :as number) &rest (step :as number))
|
||||
:returns "list"
|
||||
:doc "Integer range [start, end) with optional step.")
|
||||
|
||||
(define-primitive "get"
|
||||
:params (coll key &rest default)
|
||||
:returns "any"
|
||||
:doc "Get value from dict by key, or list by index. Optional default.")
|
||||
|
||||
(define-primitive "len"
|
||||
:params (coll)
|
||||
:returns "number"
|
||||
:doc "Length of string, list, or dict.")
|
||||
|
||||
(define-primitive "first"
|
||||
:params ((coll :as list))
|
||||
:returns "any"
|
||||
:doc "First element, or nil if empty.")
|
||||
|
||||
(define-primitive "last"
|
||||
:params ((coll :as list))
|
||||
:returns "any"
|
||||
:doc "Last element, or nil if empty.")
|
||||
|
||||
(define-primitive "rest"
|
||||
:params ((coll :as list))
|
||||
:returns "list"
|
||||
:doc "All elements except the first.")
|
||||
|
||||
(define-primitive "nth"
|
||||
:params ((coll :as list) (n :as number))
|
||||
:returns "any"
|
||||
:doc "Element at index n, or nil if out of bounds.")
|
||||
|
||||
(define-primitive "cons"
|
||||
:params (x (coll :as list))
|
||||
:returns "list"
|
||||
:doc "Prepend x to coll.")
|
||||
|
||||
(define-primitive "append"
|
||||
:params ((coll :as list) x)
|
||||
:returns "list"
|
||||
:doc "If x is a list, concatenate. Otherwise append x as single element.")
|
||||
|
||||
(define-primitive "append!"
|
||||
:params ((coll :as list) x)
|
||||
:returns "list"
|
||||
:doc "Mutate coll by appending x in-place. Returns coll.")
|
||||
|
||||
(define-primitive "reverse"
|
||||
:params ((coll :as list))
|
||||
:returns "list"
|
||||
:doc "Return coll in reverse order.")
|
||||
|
||||
(define-primitive "flatten"
|
||||
:params ((coll :as list))
|
||||
:returns "list"
|
||||
:doc "Flatten one level of nesting. Nested lists become top-level elements.")
|
||||
|
||||
(define-primitive "chunk-every"
|
||||
:params ((coll :as list) (n :as number))
|
||||
:returns "list"
|
||||
:doc "Split coll into sub-lists of size n.")
|
||||
|
||||
(define-primitive "zip-pairs"
|
||||
:params ((coll :as list))
|
||||
:returns "list"
|
||||
:doc "Consecutive pairs: (1 2 3 4) → ((1 2) (2 3) (3 4)).")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Core — Dict operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.dict)
|
||||
|
||||
(define-primitive "keys"
|
||||
:params ((d :as dict))
|
||||
:returns "list"
|
||||
:doc "List of dict keys.")
|
||||
|
||||
(define-primitive "vals"
|
||||
:params ((d :as dict))
|
||||
:returns "list"
|
||||
:doc "List of dict values.")
|
||||
|
||||
(define-primitive "merge"
|
||||
:params (&rest (dicts :as dict))
|
||||
:returns "dict"
|
||||
:doc "Merge dicts left to right. Later keys win. Skips nil.")
|
||||
|
||||
(define-primitive "has-key?"
|
||||
:params ((d :as dict) key)
|
||||
:returns "boolean"
|
||||
:doc "True if dict d contains key.")
|
||||
|
||||
(define-primitive "assoc"
|
||||
:params ((d :as dict) &rest pairs)
|
||||
:returns "dict"
|
||||
:doc "Return new dict with key/value pairs added/overwritten.")
|
||||
|
||||
(define-primitive "dissoc"
|
||||
:params ((d :as dict) &rest keys)
|
||||
:returns "dict"
|
||||
:doc "Return new dict with keys removed.")
|
||||
|
||||
(define-primitive "dict-set!"
|
||||
:params ((d :as dict) key val)
|
||||
:returns "any"
|
||||
:doc "Mutate dict d by setting key to val in-place. Returns val.")
|
||||
|
||||
(define-primitive "into"
|
||||
:params (target coll)
|
||||
:returns "any"
|
||||
:doc "Pour coll into target. List target: convert to list. Dict target: convert pairs to dict.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Stdlib — Format
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :stdlib.format)
|
||||
|
||||
(define-primitive "format-date"
|
||||
:params ((date-str :as string) (fmt :as string))
|
||||
:returns "string"
|
||||
:doc "Parse ISO date string and format with strftime-style format.")
|
||||
|
||||
(define-primitive "format-decimal"
|
||||
:params ((val :as number) &rest (places :as number))
|
||||
:returns "string"
|
||||
:doc "Format number with fixed decimal places (default 2).")
|
||||
|
||||
(define-primitive "parse-int"
|
||||
:params (val &rest default)
|
||||
:returns "number"
|
||||
:doc "Parse string to integer with optional default on failure.")
|
||||
|
||||
(define-primitive "parse-datetime"
|
||||
:params ((s :as string))
|
||||
:returns "string"
|
||||
:doc "Parse datetime string — identity passthrough (returns string or nil).")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Stdlib — Text
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :stdlib.text)
|
||||
|
||||
(define-primitive "pluralize"
|
||||
:params ((count :as number) &rest (forms :as string))
|
||||
:returns "string"
|
||||
:doc "Pluralize: (pluralize 1) → \"\", (pluralize 2) → \"s\". Or (pluralize n \"item\" \"items\").")
|
||||
|
||||
(define-primitive "escape"
|
||||
:params ((s :as string))
|
||||
:returns "string"
|
||||
:doc "HTML-escape a string (&, <, >, \", ').")
|
||||
|
||||
(define-primitive "strip-tags"
|
||||
:params ((s :as string))
|
||||
:returns "string"
|
||||
:doc "Remove HTML tags from string.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Stdlib — Style
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Stdlib — Debug
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :stdlib.debug)
|
||||
|
||||
(define-primitive "assert"
|
||||
:params (condition &rest message)
|
||||
:returns "boolean"
|
||||
:doc "Assert condition is truthy; raise error with message if not.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Type introspection — platform primitives
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :stdlib.types)
|
||||
|
||||
(define-primitive "type-of"
|
||||
:params (x)
|
||||
:returns "string"
|
||||
:doc "Return type name: number, string, boolean, nil, symbol, keyword, list, dict, lambda, component, island, macro.")
|
||||
|
||||
(define-primitive "symbol-name"
|
||||
:params ((sym :as symbol))
|
||||
:returns "string"
|
||||
:doc "Return the name string of a symbol.")
|
||||
|
||||
(define-primitive "keyword-name"
|
||||
:params ((kw :as keyword))
|
||||
:returns "string"
|
||||
:doc "Return the name string of a keyword.")
|
||||
|
||||
(define-primitive "sx-parse"
|
||||
:params ((source :as string))
|
||||
:returns "list"
|
||||
:doc "Parse SX source string into a list of AST expressions.")
|
||||
283
spec/render.sx
Normal file
283
spec/render.sx
Normal file
@@ -0,0 +1,283 @@
|
||||
;; ==========================================================================
|
||||
;; render.sx — Core rendering specification
|
||||
;;
|
||||
;; Shared registries and utilities used by all rendering adapters.
|
||||
;; This file defines WHAT is renderable (tag registries, attribute rules)
|
||||
;; and HOW arguments are parsed — but not the output format.
|
||||
;;
|
||||
;; Adapters:
|
||||
;; adapter-html.sx — HTML string output (server)
|
||||
;; adapter-sx.sx — SX wire format output (server → client)
|
||||
;; adapter-dom.sx — Live DOM node output (browser)
|
||||
;;
|
||||
;; Each adapter imports these shared definitions and provides its own
|
||||
;; render entry point (render-to-html, render-to-sx, render-to-dom).
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; HTML tag registry
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tags known to the renderer. Unknown names are treated as function calls.
|
||||
;; Void elements self-close (no children). Boolean attrs emit name only.
|
||||
|
||||
(define HTML_TAGS
|
||||
(list
|
||||
;; Document
|
||||
"html" "head" "body" "title" "meta" "link" "script" "style" "noscript"
|
||||
;; Sections
|
||||
"header" "nav" "main" "section" "article" "aside" "footer"
|
||||
"h1" "h2" "h3" "h4" "h5" "h6" "hgroup"
|
||||
;; Block
|
||||
"div" "p" "blockquote" "pre" "figure" "figcaption" "address" "details" "summary"
|
||||
;; Inline
|
||||
"a" "span" "em" "strong" "small" "b" "i" "u" "s" "mark" "sub" "sup"
|
||||
"abbr" "cite" "code" "time" "br" "wbr" "hr"
|
||||
;; Lists
|
||||
"ul" "ol" "li" "dl" "dt" "dd"
|
||||
;; Tables
|
||||
"table" "thead" "tbody" "tfoot" "tr" "th" "td" "caption" "colgroup" "col"
|
||||
;; Forms
|
||||
"form" "input" "textarea" "select" "option" "optgroup" "button" "label"
|
||||
"fieldset" "legend" "output" "datalist"
|
||||
;; Media
|
||||
"img" "video" "audio" "source" "picture" "canvas" "iframe"
|
||||
;; SVG
|
||||
"svg" "math" "path" "circle" "ellipse" "rect" "line" "polyline" "polygon"
|
||||
"text" "tspan" "g" "defs" "use" "clipPath" "mask" "pattern"
|
||||
"linearGradient" "radialGradient" "stop" "filter"
|
||||
"feGaussianBlur" "feOffset" "feBlend" "feColorMatrix" "feComposite"
|
||||
"feMerge" "feMergeNode" "feTurbulence"
|
||||
"feComponentTransfer" "feFuncR" "feFuncG" "feFuncB" "feFuncA"
|
||||
"feDisplacementMap" "feFlood" "feImage" "feMorphology"
|
||||
"feSpecularLighting" "feDiffuseLighting"
|
||||
"fePointLight" "feSpotLight" "feDistantLight"
|
||||
"animate" "animateTransform" "foreignObject"
|
||||
;; Other
|
||||
"template" "slot" "dialog" "menu"))
|
||||
|
||||
(define VOID_ELEMENTS
|
||||
(list "area" "base" "br" "col" "embed" "hr" "img" "input"
|
||||
"link" "meta" "param" "source" "track" "wbr"))
|
||||
|
||||
(define BOOLEAN_ATTRS
|
||||
(list "async" "autofocus" "autoplay" "checked" "controls" "default"
|
||||
"defer" "disabled" "formnovalidate" "hidden" "inert" "ismap"
|
||||
"loop" "multiple" "muted" "nomodule" "novalidate" "open"
|
||||
"playsinline" "readonly" "required" "reversed" "selected"))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Shared utilities
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define definition-form? :effects []
|
||||
(fn ((name :as string))
|
||||
(or (= name "define") (= name "defcomp") (= name "defisland")
|
||||
(= name "defmacro") (= name "defstyle") (= name "defhandler")
|
||||
(= name "deftype") (= name "defeffect"))))
|
||||
|
||||
|
||||
(define parse-element-args :effects [render]
|
||||
(fn ((args :as list) (env :as dict))
|
||||
;; Parse (:key val :key2 val2 child1 child2) into (attrs-dict children-list)
|
||||
(let ((attrs (dict))
|
||||
(children (list)))
|
||||
(reduce
|
||||
(fn ((state :as dict) arg)
|
||||
(let ((skip (get state "skip")))
|
||||
(if skip
|
||||
(assoc state "skip" false "i" (inc (get state "i")))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc (get state "i")) (len args)))
|
||||
(let ((val (trampoline (eval-expr (nth args (inc (get state "i"))) env))))
|
||||
(dict-set! attrs (keyword-name arg) val)
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
(do
|
||||
(append! children arg)
|
||||
(assoc state "i" (inc (get state "i"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
(list attrs children))))
|
||||
|
||||
|
||||
(define render-attrs :effects []
|
||||
(fn ((attrs :as dict))
|
||||
;; Render an attrs dict to an HTML attribute string.
|
||||
;; Used by adapter-html.sx and adapter-sx.sx.
|
||||
(join ""
|
||||
(map
|
||||
(fn ((key :as string))
|
||||
(let ((val (dict-get attrs key)))
|
||||
(cond
|
||||
;; Boolean attrs
|
||||
(and (contains? BOOLEAN_ATTRS key) val)
|
||||
(str " " key)
|
||||
(and (contains? BOOLEAN_ATTRS key) (not val))
|
||||
""
|
||||
;; Nil values — skip
|
||||
(nil? val) ""
|
||||
;; Normal attr
|
||||
:else (str " " key "=\"" (escape-attr (str val)) "\""))))
|
||||
(keys attrs)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Render adapter helpers
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Shared by HTML and DOM adapters for evaluating control forms during
|
||||
;; rendering. Unlike sf-cond (eval.sx) which returns a thunk for TCO,
|
||||
;; eval-cond returns the unevaluated body expression so the adapter
|
||||
;; can render it in its own mode (HTML string vs DOM nodes).
|
||||
|
||||
;; eval-cond: find matching cond branch, return unevaluated body expr.
|
||||
;; Handles both scheme-style ((test body) ...) and clojure-style
|
||||
;; (test body test body ...).
|
||||
(define eval-cond :effects []
|
||||
(fn ((clauses :as list) (env :as dict))
|
||||
(if (cond-scheme? clauses)
|
||||
(eval-cond-scheme clauses env)
|
||||
(eval-cond-clojure clauses env))))
|
||||
|
||||
(define eval-cond-scheme :effects []
|
||||
(fn ((clauses :as list) (env :as dict))
|
||||
(if (empty? clauses)
|
||||
nil
|
||||
(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")))
|
||||
body
|
||||
(if (trampoline (eval-expr test env))
|
||||
body
|
||||
(eval-cond-scheme (rest clauses) env)))))))
|
||||
|
||||
(define eval-cond-clojure :effects []
|
||||
(fn ((clauses :as list) (env :as dict))
|
||||
(if (< (len clauses) 2)
|
||||
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"))))
|
||||
body
|
||||
(if (trampoline (eval-expr test env))
|
||||
body
|
||||
(eval-cond-clojure (slice clauses 2) env)))))))
|
||||
|
||||
;; process-bindings: evaluate let-binding pairs, return extended env.
|
||||
;; bindings = ((name1 expr1) (name2 expr2) ...)
|
||||
(define process-bindings :effects [mutation]
|
||||
(fn ((bindings :as list) (env :as dict))
|
||||
;; env-extend (not merge) — Env is not a dict subclass, so merge()
|
||||
;; returns an empty dict, losing all parent scope bindings.
|
||||
(let ((local (env-extend env)))
|
||||
(for-each
|
||||
(fn ((pair :as list))
|
||||
(when (and (= (type-of pair) "list") (>= (len pair) 2))
|
||||
(let ((name (if (= (type-of (first pair)) "symbol")
|
||||
(symbol-name (first pair))
|
||||
(str (first pair)))))
|
||||
(env-set! local name (trampoline (eval-expr (nth pair 1) local))))))
|
||||
bindings)
|
||||
local)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; is-render-expr? — check if expression is a rendering form
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Used by eval-list to dispatch rendering forms to the active adapter
|
||||
;; (HTML, SX wire, or DOM) rather than evaluating them as function calls.
|
||||
|
||||
(define is-render-expr? :effects []
|
||||
(fn (expr)
|
||||
(if (or (not (= (type-of expr) "list")) (empty? expr))
|
||||
false
|
||||
(let ((h (first expr)))
|
||||
(if (not (= (type-of h) "symbol"))
|
||||
false
|
||||
(let ((n (symbol-name h)))
|
||||
(or (= n "<>")
|
||||
(= n "raw!")
|
||||
(starts-with? n "~")
|
||||
(starts-with? n "html:")
|
||||
(contains? HTML_TAGS n)
|
||||
(and (> (index-of n "-") 0)
|
||||
(> (len expr) 1)
|
||||
(= (type-of (nth expr 1)) "keyword")))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Spread — attribute injection from children into parent elements
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; A spread value is a dict of attributes that, when returned as a child
|
||||
;; of an HTML element, merges its attrs onto the parent element.
|
||||
;; This enables components to inject classes/styles/data-attrs onto their
|
||||
;; parent without the parent knowing about the specific attrs.
|
||||
;;
|
||||
;; merge-spread-attrs: merge a spread's attrs into an element's attrs dict.
|
||||
;; Class values are joined (space-separated); others overwrite.
|
||||
;; Mutates the target attrs dict in place.
|
||||
|
||||
(define merge-spread-attrs :effects [mutation]
|
||||
(fn ((target :as dict) (spread-dict :as dict))
|
||||
(for-each
|
||||
(fn ((key :as string))
|
||||
(let ((val (dict-get spread-dict key)))
|
||||
(if (= key "class")
|
||||
;; Class: join existing + new with space
|
||||
(let ((existing (dict-get target "class")))
|
||||
(dict-set! target "class"
|
||||
(if (and existing (not (= existing "")))
|
||||
(str existing " " val)
|
||||
val)))
|
||||
;; Style: join with semicolons
|
||||
(if (= key "style")
|
||||
(let ((existing (dict-get target "style")))
|
||||
(dict-set! target "style"
|
||||
(if (and existing (not (= existing "")))
|
||||
(str existing ";" val)
|
||||
val)))
|
||||
;; Everything else: overwrite
|
||||
(dict-set! target key val)))))
|
||||
(keys spread-dict))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 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-content r) → unwrap RawHTML marker to string
|
||||
;;
|
||||
;; Spread (render-time attribute injection):
|
||||
;; (make-spread attrs) → Spread value
|
||||
;; (spread? x) → boolean
|
||||
;; (spread-attrs s) → dict
|
||||
;;
|
||||
;; Render-time accumulators:
|
||||
;; (collect! bucket value) → void
|
||||
;; (collected bucket) → list
|
||||
;; (clear-collected! bucket) → void
|
||||
;;
|
||||
;; Scoped effects (scope/provide/context/emit!):
|
||||
;; (scope-push! name val) → void (general form)
|
||||
;; (scope-pop! name) → void (general form)
|
||||
;; (provide-push! name val) → alias for scope-push!
|
||||
;; (provide-pop! name) → alias for scope-pop!
|
||||
;; (context name &rest def) → value from nearest scope
|
||||
;; (emit! name value) → void (append to scope accumulator)
|
||||
;; (emitted name) → list of emitted values
|
||||
;;
|
||||
;; From parser.sx:
|
||||
;; (sx-serialize val) → SX source string (aliased as serialize above)
|
||||
;; --------------------------------------------------------------------------
|
||||
444
spec/special-forms.sx
Normal file
444
spec/special-forms.sx
Normal file
@@ -0,0 +1,444 @@
|
||||
;; ==========================================================================
|
||||
;; special-forms.sx — Specification of all SX special forms
|
||||
;;
|
||||
;; Special forms are syntactic constructs whose arguments are NOT evaluated
|
||||
;; before dispatch. Each form has its own evaluation rules — unlike primitives,
|
||||
;; which receive pre-evaluated values.
|
||||
;;
|
||||
;; This file is a SPECIFICATION, not executable code. Bootstrap compilers
|
||||
;; consume these declarations but implement special forms natively.
|
||||
;;
|
||||
;; Format:
|
||||
;; (define-special-form "name"
|
||||
;; :syntax (name arg1 arg2 ...)
|
||||
;; :doc "description"
|
||||
;; :tail-position "which subexpressions are in tail position"
|
||||
;; :example "(name ...)")
|
||||
;;
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Control flow
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "if"
|
||||
:syntax (if condition then-expr else-expr)
|
||||
:doc "If condition is truthy, evaluate then-expr; otherwise evaluate else-expr.
|
||||
Both branches are in tail position. The else branch is optional and
|
||||
defaults to nil."
|
||||
:tail-position "then-expr, else-expr"
|
||||
:example "(if (> x 10) \"big\" \"small\")")
|
||||
|
||||
(define-special-form "when"
|
||||
:syntax (when condition body ...)
|
||||
:doc "If condition is truthy, evaluate all body expressions sequentially.
|
||||
Returns the value of the last body expression, or nil if condition
|
||||
is falsy. Only the last body expression is in tail position."
|
||||
:tail-position "last body expression"
|
||||
:example "(when (logged-in? user)
|
||||
(render-dashboard user))")
|
||||
|
||||
(define-special-form "cond"
|
||||
:syntax (cond test1 result1 test2 result2 ... :else default)
|
||||
:doc "Multi-way conditional. Tests are evaluated in order; the result
|
||||
paired with the first truthy test is returned. The :else keyword
|
||||
(or the symbol else) matches unconditionally. Supports both
|
||||
Clojure-style flat pairs and Scheme-style nested pairs:
|
||||
Clojure: (cond test1 result1 test2 result2 :else default)
|
||||
Scheme: (cond (test1 result1) (test2 result2) (else default))"
|
||||
:tail-position "all result expressions"
|
||||
:example "(cond
|
||||
(= status \"active\") (render-active item)
|
||||
(= status \"draft\") (render-draft item)
|
||||
:else (render-unknown item))")
|
||||
|
||||
(define-special-form "case"
|
||||
:syntax (case expr val1 result1 val2 result2 ... :else default)
|
||||
:doc "Match expr against values using equality. Like cond but tests
|
||||
a single expression against multiple values. The :else keyword
|
||||
matches if no values match."
|
||||
:tail-position "all result expressions"
|
||||
:example "(case (get request \"method\")
|
||||
\"GET\" (handle-get request)
|
||||
\"POST\" (handle-post request)
|
||||
:else (method-not-allowed))")
|
||||
|
||||
(define-special-form "and"
|
||||
:syntax (and expr ...)
|
||||
:doc "Short-circuit logical AND. Evaluates expressions left to right.
|
||||
Returns the first falsy value, or the last value if all are truthy.
|
||||
Returns true if given no arguments."
|
||||
:tail-position "last expression"
|
||||
:example "(and (valid? input) (authorized? user) (process input))")
|
||||
|
||||
(define-special-form "or"
|
||||
:syntax (or expr ...)
|
||||
:doc "Short-circuit logical OR. Evaluates expressions left to right.
|
||||
Returns the first truthy value, or the last value if all are falsy.
|
||||
Returns false if given no arguments."
|
||||
:tail-position "last expression"
|
||||
:example "(or (get cache key) (fetch-from-db key) \"default\")")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Binding
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "let"
|
||||
:syntax (let bindings body ...)
|
||||
:doc "Create local bindings and evaluate body in the extended environment.
|
||||
Bindings can be Scheme-style ((name val) ...) or Clojure-style
|
||||
(name val name val ...). Each binding can see previous bindings.
|
||||
Only the last body expression is in tail position.
|
||||
|
||||
Named let: (let name ((x init) ...) body) creates a loop. The name
|
||||
is bound to a function that takes the same params and recurses with
|
||||
tail-call optimization."
|
||||
:tail-position "last body expression; recursive call in named let"
|
||||
:example ";; Basic let
|
||||
(let ((x 10) (y 20))
|
||||
(+ x y))
|
||||
|
||||
;; Clojure-style
|
||||
(let (x 10 y 20)
|
||||
(+ x y))
|
||||
|
||||
;; Named let (loop)
|
||||
(let loop ((i 0) (acc 0))
|
||||
(if (= i 100)
|
||||
acc
|
||||
(loop (+ i 1) (+ acc i))))")
|
||||
|
||||
(define-special-form "let*"
|
||||
:syntax (let* bindings body ...)
|
||||
:doc "Alias for let. In SX, let is already sequential (each binding
|
||||
sees previous ones), so let* is identical to let."
|
||||
:tail-position "last body expression"
|
||||
:example "(let* ((x 10) (y (* x 2)))
|
||||
(+ x y)) ;; → 30")
|
||||
|
||||
(define-special-form "letrec"
|
||||
:syntax (letrec bindings body ...)
|
||||
:doc "Mutually recursive local bindings. All names are bound to nil first,
|
||||
then all values are evaluated (so they can reference each other),
|
||||
then lambda closures are patched to include the final bindings.
|
||||
Used for defining mutually recursive local functions."
|
||||
:tail-position "last body expression"
|
||||
:example "(letrec ((even? (fn (n) (if (= n 0) true (odd? (- n 1)))))
|
||||
(odd? (fn (n) (if (= n 0) false (even? (- n 1))))))
|
||||
(even? 10)) ;; → true")
|
||||
|
||||
(define-special-form "define"
|
||||
:syntax (define name value)
|
||||
:doc "Bind name to value in the current environment. If value is a lambda
|
||||
and has no name, the lambda's name is set to the symbol name.
|
||||
Returns the value."
|
||||
:tail-position "none (value is eagerly evaluated)"
|
||||
:example "(define greeting \"hello\")
|
||||
(define double (fn (x) (* x 2)))")
|
||||
|
||||
(define-special-form "set!"
|
||||
:syntax (set! name value)
|
||||
:doc "Mutate an existing binding. The name must already be bound in the
|
||||
current environment. Returns the new value."
|
||||
:tail-position "none (value is eagerly evaluated)"
|
||||
:example "(let (count 0)
|
||||
(set! count (+ count 1)))")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Functions and components
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "lambda"
|
||||
:syntax (lambda params body)
|
||||
:doc "Create a function. Params is a list of parameter names. Body is
|
||||
a single expression (the return value). The lambda captures the
|
||||
current environment as its closure."
|
||||
:tail-position "body"
|
||||
:example "(lambda (x y) (+ x y))")
|
||||
|
||||
(define-special-form "fn"
|
||||
:syntax (fn params body)
|
||||
:doc "Alias for lambda."
|
||||
:tail-position "body"
|
||||
:example "(fn (x) (* x x))")
|
||||
|
||||
(define-special-form "defcomp"
|
||||
:syntax (defcomp ~name (&key param1 param2 &rest children) body)
|
||||
:doc "Define a component. Components are called with keyword arguments
|
||||
and optional positional children. The &key marker introduces
|
||||
keyword parameters. The &rest (or &children) marker captures
|
||||
remaining positional arguments as a list.
|
||||
|
||||
Component names conventionally start with ~ to distinguish them
|
||||
from HTML elements. Components are evaluated with a merged
|
||||
environment: closure + caller-env + bound-params."
|
||||
:tail-position "body"
|
||||
:example "(defcomp ~card (&key title subtitle &rest children)
|
||||
(div :class \"card\"
|
||||
(h2 title)
|
||||
(when subtitle (p subtitle))
|
||||
children))")
|
||||
|
||||
(define-special-form "defisland"
|
||||
:syntax (defisland ~name (&key param1 param2 &rest children) body)
|
||||
:doc "Define a reactive island. Islands have the same calling convention
|
||||
as components (defcomp) but create a reactive boundary. Inside an
|
||||
island, signals are tracked — deref subscribes DOM nodes to signals,
|
||||
and signal changes update only the affected nodes.
|
||||
|
||||
On the server, islands render as static HTML wrapped in a
|
||||
data-sx-island container with serialized initial state. On the
|
||||
client, islands hydrate into reactive contexts."
|
||||
:tail-position "body"
|
||||
:example "(defisland ~counter (&key initial)
|
||||
(let ((count (signal (or initial 0))))
|
||||
(div :class \"counter\"
|
||||
(span (deref count))
|
||||
(button :on-click (fn (e) (swap! count inc)) \"+\"))))")
|
||||
|
||||
(define-special-form "defmacro"
|
||||
:syntax (defmacro name (params ...) body)
|
||||
:doc "Define a macro. Macros receive their arguments unevaluated (as raw
|
||||
AST) and return a new expression that is then evaluated. The
|
||||
returned expression replaces the macro call. Use quasiquote for
|
||||
template construction."
|
||||
:tail-position "none (expansion is evaluated separately)"
|
||||
:example "(defmacro unless (condition &rest body)
|
||||
`(when (not ~condition) ~@body))")
|
||||
|
||||
(define-special-form "deftype"
|
||||
:syntax (deftype name body)
|
||||
:doc "Define a named type. The name can be a simple symbol for type aliases
|
||||
and records, or a list (name param ...) for parameterized types.
|
||||
Body is a type expression: a symbol (alias), (union t1 t2 ...) for
|
||||
union types, or {:field1 type1 :field2 type2} for record types.
|
||||
Type definitions are metadata for the type checker with no runtime cost."
|
||||
:tail-position "none"
|
||||
:example "(deftype price number)
|
||||
(deftype card-props {:title string :price number})
|
||||
(deftype (maybe a) (union a nil))")
|
||||
|
||||
(define-special-form "defeffect"
|
||||
:syntax (defeffect name)
|
||||
:doc "Declare a named effect. Effects annotate functions and components
|
||||
to track side effects. A pure function (:effects [pure]) cannot
|
||||
call IO functions. Unannotated functions are assumed to have all
|
||||
effects. Effect checking is gradual — annotations opt in."
|
||||
:tail-position "none"
|
||||
:example "(defeffect io)
|
||||
(defeffect async)
|
||||
(define add :effects [pure] (fn (a b) (+ a b)))")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Sequencing and threading
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "begin"
|
||||
:syntax (begin expr ...)
|
||||
:doc "Evaluate expressions sequentially. Returns the value of the last
|
||||
expression. Used when multiple side-effecting expressions need
|
||||
to be grouped."
|
||||
:tail-position "last expression"
|
||||
:example "(begin
|
||||
(log \"starting\")
|
||||
(process data)
|
||||
(log \"done\"))")
|
||||
|
||||
(define-special-form "do"
|
||||
:syntax (do expr ...)
|
||||
:doc "Alias for begin."
|
||||
:tail-position "last expression"
|
||||
:example "(do (set! x 1) (set! y 2) (+ x y))")
|
||||
|
||||
(define-special-form "->"
|
||||
:syntax (-> value form1 form2 ...)
|
||||
:doc "Thread-first macro. Threads value through a series of function calls,
|
||||
inserting it as the first argument of each form. Nested lists are
|
||||
treated as function calls; bare symbols become unary calls."
|
||||
:tail-position "last form"
|
||||
:example "(-> user
|
||||
(get \"name\")
|
||||
upper
|
||||
(str \" says hello\"))
|
||||
;; Expands to: (str (upper (get user \"name\")) \" says hello\")")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Quoting
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "quote"
|
||||
:syntax (quote expr)
|
||||
:doc "Return expr as data, without evaluating it. Symbols remain symbols,
|
||||
lists remain lists. The reader shorthand is the ' prefix."
|
||||
:tail-position "none (not evaluated)"
|
||||
:example "'(+ 1 2) ;; → the list (+ 1 2), not the number 3")
|
||||
|
||||
(define-special-form "quasiquote"
|
||||
:syntax (quasiquote expr)
|
||||
:doc "Template construction. Like quote, but allows unquoting with ~ and
|
||||
splicing with ~@. The reader shorthand is the ` prefix.
|
||||
`(a ~b ~@c)
|
||||
Quotes everything except: ~expr evaluates expr and inserts the
|
||||
result; ~@expr evaluates to a list and splices its elements."
|
||||
:tail-position "none (template is constructed, not evaluated)"
|
||||
:example "`(div :class \"card\" ~title ~@children)")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Continuations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "reset"
|
||||
:syntax (reset body)
|
||||
:doc "Establish a continuation delimiter. Evaluates body normally unless
|
||||
a shift is encountered, in which case the continuation (the rest
|
||||
of the computation up to this reset) is captured and passed to
|
||||
the shift's body. Without shift, reset is a no-op wrapper."
|
||||
:tail-position "body"
|
||||
:example "(reset (+ 1 (shift k (k 10)))) ;; → 11")
|
||||
|
||||
(define-special-form "shift"
|
||||
:syntax (shift k body)
|
||||
:doc "Capture the continuation to the nearest reset as k, then evaluate
|
||||
body with k bound. If k is never called, the value of body is
|
||||
returned from the reset (abort). If k is called with a value,
|
||||
the reset body is re-evaluated with shift returning that value.
|
||||
k can be called multiple times."
|
||||
:tail-position "body"
|
||||
:example ";; Abort: shift body becomes the reset result
|
||||
(reset (+ 1 (shift k 42))) ;; → 42
|
||||
|
||||
;; Resume: k re-enters the computation
|
||||
(reset (+ 1 (shift k (k 10)))) ;; → 11
|
||||
|
||||
;; Multiple invocations
|
||||
(reset (* 2 (shift k (+ (k 1) (k 10))))) ;; → 24")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Guards
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "dynamic-wind"
|
||||
:syntax (dynamic-wind before-thunk body-thunk after-thunk)
|
||||
:doc "Entry/exit guards. All three arguments are zero-argument functions
|
||||
(thunks). before-thunk is called on entry, body-thunk is called
|
||||
for the result, and after-thunk is always called on exit (even on
|
||||
error). The wind stack is maintained so that when continuations
|
||||
jump across dynamic-wind boundaries, the correct before/after
|
||||
thunks fire."
|
||||
:tail-position "none (all thunks are eagerly called)"
|
||||
:example "(dynamic-wind
|
||||
(fn () (log \"entering\"))
|
||||
(fn () (do-work))
|
||||
(fn () (log \"exiting\")))")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Higher-order forms
|
||||
;;
|
||||
;; These are syntactic forms (not primitives) because the evaluator
|
||||
;; handles them directly for performance — avoiding the overhead of
|
||||
;; constructing argument lists and doing generic dispatch. They could
|
||||
;; be implemented as primitives but are special-cased in eval-list.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "map"
|
||||
:syntax (map fn coll)
|
||||
:doc "Apply fn to each element of coll, returning a list of results."
|
||||
:tail-position "none"
|
||||
:example "(map (fn (x) (* x x)) (list 1 2 3 4)) ;; → (1 4 9 16)")
|
||||
|
||||
(define-special-form "map-indexed"
|
||||
:syntax (map-indexed fn coll)
|
||||
:doc "Like map, but fn receives two arguments: (index element)."
|
||||
:tail-position "none"
|
||||
:example "(map-indexed (fn (i x) (str i \": \" x)) (list \"a\" \"b\" \"c\"))")
|
||||
|
||||
(define-special-form "filter"
|
||||
:syntax (filter fn coll)
|
||||
:doc "Return elements of coll for which fn returns truthy."
|
||||
:tail-position "none"
|
||||
:example "(filter (fn (x) (> x 3)) (list 1 5 2 8 3)) ;; → (5 8)")
|
||||
|
||||
(define-special-form "reduce"
|
||||
:syntax (reduce fn init coll)
|
||||
:doc "Reduce coll to a single value. fn receives (accumulator element)
|
||||
and returns the new accumulator. init is the initial value."
|
||||
:tail-position "none"
|
||||
:example "(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4)) ;; → 10")
|
||||
|
||||
(define-special-form "some"
|
||||
:syntax (some fn coll)
|
||||
:doc "Return the first truthy result of applying fn to elements of coll,
|
||||
or nil if none match. Short-circuits on first truthy result."
|
||||
:tail-position "none"
|
||||
:example "(some (fn (x) (> x 3)) (list 1 2 5 3)) ;; → true")
|
||||
|
||||
(define-special-form "every?"
|
||||
:syntax (every? fn coll)
|
||||
:doc "Return true if fn returns truthy for every element of coll.
|
||||
Short-circuits on first falsy result."
|
||||
:tail-position "none"
|
||||
:example "(every? (fn (x) (> x 0)) (list 1 2 3)) ;; → true")
|
||||
|
||||
(define-special-form "for-each"
|
||||
:syntax (for-each fn coll)
|
||||
:doc "Apply fn to each element of coll for side effects. Returns nil."
|
||||
:tail-position "none"
|
||||
:example "(for-each (fn (x) (log x)) (list 1 2 3))")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Definition forms (domain-specific)
|
||||
;;
|
||||
;; These define named entities in the environment. They are special forms
|
||||
;; because their arguments have domain-specific structure that the
|
||||
;; evaluator parses directly.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "defstyle"
|
||||
:syntax (defstyle name expr)
|
||||
:doc "Define a named style value. Evaluates expr and binds the result
|
||||
to name in the environment. The value is typically a class string
|
||||
or a function that returns class strings."
|
||||
:tail-position "none"
|
||||
:example "(defstyle card-style \"rounded-lg shadow-md p-4 bg-white\")")
|
||||
|
||||
(define-special-form "defhandler"
|
||||
:syntax (defhandler name (&key params ...) body)
|
||||
:doc "Define an event handler function. Used by the SxEngine for
|
||||
client-side event handling."
|
||||
:tail-position "body"
|
||||
:example "(defhandler toggle-menu (&key target)
|
||||
(toggle-class target \"hidden\"))")
|
||||
|
||||
(define-special-form "defpage"
|
||||
:syntax (defpage name &key route method content ...)
|
||||
:doc "Define a page route. Declares the URL pattern, HTTP method, and
|
||||
content component for server-side page routing."
|
||||
:tail-position "none"
|
||||
:example "(defpage dashboard-page
|
||||
:route \"/dashboard\"
|
||||
:content (~dashboard-content))")
|
||||
|
||||
(define-special-form "defquery"
|
||||
:syntax (defquery name (&key params ...) body)
|
||||
:doc "Define a named query for data fetching. Used by the resolver
|
||||
system to declare data dependencies."
|
||||
:tail-position "body"
|
||||
:example "(defquery user-profile (&key user-id)
|
||||
(fetch-user user-id))")
|
||||
|
||||
(define-special-form "defaction"
|
||||
:syntax (defaction name (&key params ...) body)
|
||||
:doc "Define a named action for mutations. Like defquery but for
|
||||
write operations."
|
||||
:tail-position "body"
|
||||
:example "(defaction update-profile (&key user-id name email)
|
||||
(save-user user-id name email))")
|
||||
241
spec/test-cek.sx
Normal file
241
spec/test-cek.sx
Normal file
@@ -0,0 +1,241 @@
|
||||
;; ==========================================================================
|
||||
;; test-cek.sx — Tests for the explicit CEK machine evaluator
|
||||
;;
|
||||
;; Tests that eval-expr-cek produces identical results to eval-expr.
|
||||
;; Requires: test-framework.sx, frames.sx, cek.sx loaded.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Literals
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-literals"
|
||||
(deftest "number"
|
||||
(assert-equal 42 (eval-expr-cek 42 (test-env))))
|
||||
|
||||
(deftest "string"
|
||||
(assert-equal "hello" (eval-expr-cek "hello" (test-env))))
|
||||
|
||||
(deftest "boolean true"
|
||||
(assert-equal true (eval-expr-cek true (test-env))))
|
||||
|
||||
(deftest "boolean false"
|
||||
(assert-equal false (eval-expr-cek false (test-env))))
|
||||
|
||||
(deftest "nil"
|
||||
(assert-nil (eval-expr-cek nil (test-env)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Symbol lookup
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-symbols"
|
||||
(deftest "env lookup"
|
||||
(assert-equal 42
|
||||
(cek-eval "(do (define x 42) x)")))
|
||||
|
||||
(deftest "primitive call resolves"
|
||||
(assert-equal "hello"
|
||||
(cek-eval "(str \"hello\")"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Special forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-if"
|
||||
(deftest "if true branch"
|
||||
(assert-equal 1
|
||||
(cek-eval "(if true 1 2)")))
|
||||
|
||||
(deftest "if false branch"
|
||||
(assert-equal 2
|
||||
(cek-eval "(if false 1 2)")))
|
||||
|
||||
(deftest "if no else"
|
||||
(assert-nil (cek-eval "(if false 1)"))))
|
||||
|
||||
|
||||
(defsuite "cek-when"
|
||||
(deftest "when true"
|
||||
(assert-equal 42
|
||||
(cek-eval "(when true 42)")))
|
||||
|
||||
(deftest "when false"
|
||||
(assert-nil (cek-eval "(when false 42)")))
|
||||
|
||||
(deftest "when multiple body"
|
||||
(assert-equal 3
|
||||
(cek-eval "(when true 1 2 3)"))))
|
||||
|
||||
|
||||
(defsuite "cek-begin"
|
||||
(deftest "do returns last"
|
||||
(assert-equal 3
|
||||
(cek-eval "(do 1 2 3)")))
|
||||
|
||||
(deftest "empty do"
|
||||
(assert-nil (cek-eval "(do)"))))
|
||||
|
||||
|
||||
(defsuite "cek-let"
|
||||
(deftest "basic let"
|
||||
(assert-equal 3
|
||||
(cek-eval "(let ((x 1) (y 2)) (+ x y))")))
|
||||
|
||||
(deftest "let body sequence"
|
||||
(assert-equal 10
|
||||
(cek-eval "(let ((x 5)) 1 2 (+ x 5))")))
|
||||
|
||||
(deftest "nested let"
|
||||
(assert-equal 5
|
||||
(cek-eval "(let ((x 1)) (let ((y 2)) (+ x y (* x y))))"))))
|
||||
|
||||
|
||||
(defsuite "cek-and-or"
|
||||
(deftest "and all true"
|
||||
(assert-equal 3
|
||||
(cek-eval "(and 1 2 3)")))
|
||||
|
||||
(deftest "and short circuit"
|
||||
(assert-false (cek-eval "(and 1 false 3)")))
|
||||
|
||||
(deftest "or first true"
|
||||
(assert-equal 1
|
||||
(cek-eval "(or 1 2 3)")))
|
||||
|
||||
(deftest "or all false"
|
||||
(assert-false (cek-eval "(or false false false)"))))
|
||||
|
||||
|
||||
(defsuite "cek-cond"
|
||||
(deftest "cond first match"
|
||||
(assert-equal "a"
|
||||
(cek-eval "(cond true \"a\" true \"b\")")))
|
||||
|
||||
(deftest "cond second match"
|
||||
(assert-equal "b"
|
||||
(cek-eval "(cond false \"a\" true \"b\")")))
|
||||
|
||||
(deftest "cond else"
|
||||
(assert-equal "c"
|
||||
(cek-eval "(cond false \"a\" :else \"c\")"))))
|
||||
|
||||
|
||||
(defsuite "cek-case"
|
||||
(deftest "case match"
|
||||
(assert-equal "yes"
|
||||
(cek-eval "(case 1 1 \"yes\" 2 \"no\")")))
|
||||
|
||||
(deftest "case no match"
|
||||
(assert-nil
|
||||
(cek-eval "(case 3 1 \"yes\" 2 \"no\")")))
|
||||
|
||||
(deftest "case else"
|
||||
(assert-equal "default"
|
||||
(cek-eval "(case 3 1 \"yes\" :else \"default\")"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Function calls
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-calls"
|
||||
(deftest "primitive call"
|
||||
(assert-equal 3
|
||||
(cek-eval "(+ 1 2)")))
|
||||
|
||||
(deftest "nested calls"
|
||||
(assert-equal 6
|
||||
(cek-eval "(+ 1 (+ 2 3))")))
|
||||
|
||||
(deftest "lambda call"
|
||||
(assert-equal 10
|
||||
(cek-eval "((fn (x) (* x 2)) 5)")))
|
||||
|
||||
(deftest "defined function"
|
||||
(assert-equal 25
|
||||
(cek-eval "(do (define square (fn (x) (* x x))) (square 5))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Define and set!
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-define"
|
||||
(deftest "define binds"
|
||||
(assert-equal 42
|
||||
(cek-eval "(do (define x 42) x)")))
|
||||
|
||||
(deftest "set! mutates"
|
||||
(assert-equal 10
|
||||
(cek-eval "(do (define x 1) (set! x 10) x)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Quote and quasiquote
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-quote"
|
||||
(deftest "quote"
|
||||
(let ((result (cek-eval "(quote (1 2 3))")))
|
||||
(assert-equal 3 (len result))))
|
||||
|
||||
(deftest "quasiquote with unquote"
|
||||
(assert-equal (list 1 42 3)
|
||||
(cek-eval "(let ((x 42)) `(1 ,x 3))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. Thread-first
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-thread-first"
|
||||
(deftest "simple thread"
|
||||
(assert-equal 3
|
||||
(cek-eval "(-> 1 (+ 2))")))
|
||||
|
||||
(deftest "multi-step thread"
|
||||
(assert-equal 6
|
||||
(cek-eval "(-> 1 (+ 2) (* 2))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. CEK-specific: stepping
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-stepping"
|
||||
(deftest "single step literal"
|
||||
(let ((state (make-cek-state 42 (test-env) (list))))
|
||||
(let ((stepped (cek-step state)))
|
||||
(assert-equal "continue" (cek-phase stepped))
|
||||
(assert-equal 42 (cek-value stepped))
|
||||
(assert-true (cek-terminal? stepped)))))
|
||||
|
||||
(deftest "single step if pushes frame"
|
||||
(let ((state (make-cek-state (sx-parse-one "(if true 1 2)") (test-env) (list))))
|
||||
(let ((stepped (cek-step state)))
|
||||
(assert-equal "eval" (cek-phase stepped))
|
||||
;; Should have pushed an IfFrame
|
||||
(assert-true (> (len (cek-kont stepped)) 0))
|
||||
(assert-equal "if" (frame-type (first (cek-kont stepped))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. Native continuations (shift/reset in CEK)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-continuations"
|
||||
(deftest "reset passthrough"
|
||||
(assert-equal 42
|
||||
(cek-eval "(reset 42)")))
|
||||
|
||||
(deftest "shift abort"
|
||||
(assert-equal 42
|
||||
(cek-eval "(reset (+ 1 (shift k 42)))")))
|
||||
|
||||
(deftest "shift with invoke"
|
||||
(assert-equal 11
|
||||
(cek-eval "(reset (+ 1 (shift k (k 10))))"))))
|
||||
140
spec/test-continuations.sx
Normal file
140
spec/test-continuations.sx
Normal file
@@ -0,0 +1,140 @@
|
||||
;; ==========================================================================
|
||||
;; test-continuations.sx — Tests for delimited continuations (shift/reset)
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded, continuations extension enabled.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Basic shift/reset
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "basic-shift-reset"
|
||||
(deftest "reset passthrough"
|
||||
(assert-equal 42 (reset 42)))
|
||||
|
||||
(deftest "reset evaluates expression"
|
||||
(assert-equal 3 (reset (+ 1 2))))
|
||||
|
||||
(deftest "shift aborts to reset"
|
||||
(assert-equal 42 (reset (+ 1 (shift k 42)))))
|
||||
|
||||
(deftest "shift with single invoke"
|
||||
(assert-equal 11 (reset (+ 1 (shift k (k 10))))))
|
||||
|
||||
(deftest "shift with multiple invokes"
|
||||
(assert-equal (list 11 21)
|
||||
(reset (+ 1 (shift k (list (k 10) (k 20)))))))
|
||||
|
||||
(deftest "shift returns string"
|
||||
(assert-equal "aborted"
|
||||
(reset (+ 1 (shift k "aborted")))))
|
||||
|
||||
(deftest "shift returns nil"
|
||||
(assert-nil (reset (+ 1 (shift k nil)))))
|
||||
|
||||
(deftest "nested expression with shift"
|
||||
(assert-equal 16
|
||||
(+ 1 (reset (+ 10 (shift k (k 5))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Continuation predicates
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "continuation-predicates"
|
||||
(deftest "k is a continuation inside shift"
|
||||
(assert-true
|
||||
(reset (shift k (continuation? k)))))
|
||||
|
||||
(deftest "number is not a continuation"
|
||||
(assert-false (continuation? 42)))
|
||||
|
||||
(deftest "function is not a continuation"
|
||||
(assert-false (continuation? (fn (x) x))))
|
||||
|
||||
(deftest "nil is not a continuation"
|
||||
(assert-false (continuation? nil)))
|
||||
|
||||
(deftest "string is not a continuation"
|
||||
(assert-false (continuation? "hello"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Continuation as value
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "continuation-as-value"
|
||||
(deftest "k returned from reset"
|
||||
;; shift body returns k itself — reset returns the continuation
|
||||
(let ((k (reset (+ 1 (shift k k)))))
|
||||
(assert-true (continuation? k))
|
||||
(assert-equal 11 (k 10))))
|
||||
|
||||
(deftest "invoke returned k multiple times"
|
||||
(let ((k (reset (+ 1 (shift k k)))))
|
||||
(assert-equal 11 (k 10))
|
||||
(assert-equal 21 (k 20))
|
||||
(assert-equal 2 (k 1))))
|
||||
|
||||
(deftest "pass k to another function"
|
||||
(let ((apply-k (fn (k v) (k v))))
|
||||
(assert-equal 15
|
||||
(reset (+ 5 (shift k (apply-k k 10)))))))
|
||||
|
||||
(deftest "k in data structure"
|
||||
(let ((result (reset (+ 1 (shift k (list k 42))))))
|
||||
(assert-equal 42 (nth result 1))
|
||||
(assert-equal 100 ((first result) 99)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Nested reset
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "nested-reset"
|
||||
(deftest "inner reset captures independently"
|
||||
(assert-equal 12
|
||||
(reset (+ 1 (reset (+ 10 (shift k (k 1))))))))
|
||||
|
||||
(deftest "inner abort outer continues"
|
||||
(assert-equal 43
|
||||
(reset (+ 1 (reset (+ 10 (shift k 42)))))))
|
||||
|
||||
(deftest "outer shift captures outer reset"
|
||||
(assert-equal 100
|
||||
(reset (+ 1 (shift k (k 99)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Interaction with scoped effects
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "continuations-with-scopes"
|
||||
(deftest "provide survives resume"
|
||||
(assert-equal "dark"
|
||||
(reset (provide "theme" "dark"
|
||||
(+ 0 (shift k (k 0)))
|
||||
(context "theme")))))
|
||||
|
||||
(deftest "scope and emit across shift"
|
||||
(assert-equal (list "a")
|
||||
(reset (scope "acc"
|
||||
(emit! "acc" "a")
|
||||
(+ 0 (shift k (k 0)))
|
||||
(emitted "acc"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. TCO interaction
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "tco-interaction"
|
||||
(deftest "shift in tail position"
|
||||
(assert-equal 42
|
||||
(reset (if true (shift k (k 42)) 0))))
|
||||
|
||||
(deftest "shift in let body"
|
||||
(assert-equal 10
|
||||
(reset (let ((x 5))
|
||||
(+ x (shift k (k 5))))))))
|
||||
746
spec/test-eval.sx
Normal file
746
spec/test-eval.sx
Normal file
@@ -0,0 +1,746 @@
|
||||
;; ==========================================================================
|
||||
;; test-eval.sx — Tests for the core evaluator and primitives
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: eval.sx, primitives.sx
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Literals and types
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "literals"
|
||||
(deftest "numbers are numbers"
|
||||
(assert-type "number" 42)
|
||||
(assert-type "number" 3.14)
|
||||
(assert-type "number" -1))
|
||||
|
||||
(deftest "strings are strings"
|
||||
(assert-type "string" "hello")
|
||||
(assert-type "string" ""))
|
||||
|
||||
(deftest "booleans are booleans"
|
||||
(assert-type "boolean" true)
|
||||
(assert-type "boolean" false))
|
||||
|
||||
(deftest "nil is nil"
|
||||
(assert-type "nil" nil)
|
||||
(assert-nil nil))
|
||||
|
||||
(deftest "lists are lists"
|
||||
(assert-type "list" (list 1 2 3))
|
||||
(assert-type "list" (list)))
|
||||
|
||||
(deftest "dicts are dicts"
|
||||
(assert-type "dict" {:a 1 :b 2})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Arithmetic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "arithmetic"
|
||||
(deftest "addition"
|
||||
(assert-equal 3 (+ 1 2))
|
||||
(assert-equal 0 (+ 0 0))
|
||||
(assert-equal -1 (+ 1 -2))
|
||||
(assert-equal 10 (+ 1 2 3 4)))
|
||||
|
||||
(deftest "subtraction"
|
||||
(assert-equal 1 (- 3 2))
|
||||
(assert-equal -1 (- 2 3)))
|
||||
|
||||
(deftest "multiplication"
|
||||
(assert-equal 6 (* 2 3))
|
||||
(assert-equal 0 (* 0 100))
|
||||
(assert-equal 24 (* 1 2 3 4)))
|
||||
|
||||
(deftest "division"
|
||||
(assert-equal 2 (/ 6 3))
|
||||
(assert-equal 2.5 (/ 5 2)))
|
||||
|
||||
(deftest "modulo"
|
||||
(assert-equal 1 (mod 7 3))
|
||||
(assert-equal 0 (mod 6 3))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Comparison
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "comparison"
|
||||
(deftest "equality"
|
||||
(assert-true (= 1 1))
|
||||
(assert-false (= 1 2))
|
||||
(assert-true (= "a" "a"))
|
||||
(assert-false (= "a" "b")))
|
||||
|
||||
(deftest "deep equality"
|
||||
(assert-true (equal? (list 1 2 3) (list 1 2 3)))
|
||||
(assert-false (equal? (list 1 2) (list 1 3)))
|
||||
(assert-true (equal? {:a 1} {:a 1}))
|
||||
(assert-false (equal? {:a 1} {:a 2})))
|
||||
|
||||
(deftest "ordering"
|
||||
(assert-true (< 1 2))
|
||||
(assert-false (< 2 1))
|
||||
(assert-true (> 2 1))
|
||||
(assert-true (<= 1 1))
|
||||
(assert-true (<= 1 2))
|
||||
(assert-true (>= 2 2))
|
||||
(assert-true (>= 3 2))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; String operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "strings"
|
||||
(deftest "str concatenation"
|
||||
(assert-equal "abc" (str "a" "b" "c"))
|
||||
(assert-equal "hello world" (str "hello" " " "world"))
|
||||
(assert-equal "42" (str 42))
|
||||
(assert-equal "" (str)))
|
||||
|
||||
(deftest "string-length"
|
||||
(assert-equal 5 (string-length "hello"))
|
||||
(assert-equal 0 (string-length "")))
|
||||
|
||||
(deftest "substring"
|
||||
(assert-equal "ell" (substring "hello" 1 4))
|
||||
(assert-equal "hello" (substring "hello" 0 5)))
|
||||
|
||||
(deftest "string-contains?"
|
||||
(assert-true (string-contains? "hello world" "world"))
|
||||
(assert-false (string-contains? "hello" "xyz")))
|
||||
|
||||
(deftest "upcase and downcase"
|
||||
(assert-equal "HELLO" (upcase "hello"))
|
||||
(assert-equal "hello" (downcase "HELLO")))
|
||||
|
||||
(deftest "trim"
|
||||
(assert-equal "hello" (trim " hello "))
|
||||
(assert-equal "hello" (trim "hello")))
|
||||
|
||||
(deftest "split and join"
|
||||
(assert-equal (list "a" "b" "c") (split "a,b,c" ","))
|
||||
(assert-equal "a-b-c" (join "-" (list "a" "b" "c")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; List operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "lists"
|
||||
(deftest "constructors"
|
||||
(assert-equal (list 1 2 3) (list 1 2 3))
|
||||
(assert-equal (list) (list))
|
||||
(assert-length 3 (list 1 2 3)))
|
||||
|
||||
(deftest "first and rest"
|
||||
(assert-equal 1 (first (list 1 2 3)))
|
||||
(assert-equal (list 2 3) (rest (list 1 2 3)))
|
||||
(assert-nil (first (list)))
|
||||
(assert-equal (list) (rest (list))))
|
||||
|
||||
(deftest "nth"
|
||||
(assert-equal 1 (nth (list 1 2 3) 0))
|
||||
(assert-equal 2 (nth (list 1 2 3) 1))
|
||||
(assert-equal 3 (nth (list 1 2 3) 2)))
|
||||
|
||||
(deftest "last"
|
||||
(assert-equal 3 (last (list 1 2 3)))
|
||||
(assert-nil (last (list))))
|
||||
|
||||
(deftest "cons and append"
|
||||
(assert-equal (list 0 1 2) (cons 0 (list 1 2)))
|
||||
(assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))
|
||||
|
||||
(deftest "reverse"
|
||||
(assert-equal (list 3 2 1) (reverse (list 1 2 3)))
|
||||
(assert-equal (list) (reverse (list))))
|
||||
|
||||
(deftest "empty?"
|
||||
(assert-true (empty? (list)))
|
||||
(assert-false (empty? (list 1))))
|
||||
|
||||
(deftest "len"
|
||||
(assert-equal 0 (len (list)))
|
||||
(assert-equal 3 (len (list 1 2 3))))
|
||||
|
||||
(deftest "contains?"
|
||||
(assert-true (contains? (list 1 2 3) 2))
|
||||
(assert-false (contains? (list 1 2 3) 4)))
|
||||
|
||||
(deftest "flatten"
|
||||
(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Dict operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "dicts"
|
||||
(deftest "dict literal"
|
||||
(assert-type "dict" {:a 1 :b 2})
|
||||
(assert-equal 1 (get {:a 1} "a"))
|
||||
(assert-equal 2 (get {:a 1 :b 2} "b")))
|
||||
|
||||
(deftest "assoc"
|
||||
(assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2))
|
||||
(assert-equal {:a 99} (assoc {:a 1} "a" 99)))
|
||||
|
||||
(deftest "dissoc"
|
||||
(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a")))
|
||||
|
||||
(deftest "keys and vals"
|
||||
(let ((d {:a 1 :b 2}))
|
||||
(assert-length 2 (keys d))
|
||||
(assert-length 2 (vals d))
|
||||
(assert-contains "a" (keys d))
|
||||
(assert-contains "b" (keys d))))
|
||||
|
||||
(deftest "has-key?"
|
||||
(assert-true (has-key? {:a 1} "a"))
|
||||
(assert-false (has-key? {:a 1} "b")))
|
||||
|
||||
(deftest "merge"
|
||||
(assert-equal {:a 1 :b 2 :c 3}
|
||||
(merge {:a 1 :b 2} {:c 3}))
|
||||
(assert-equal {:a 99 :b 2}
|
||||
(merge {:a 1 :b 2} {:a 99}))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Predicates
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "predicates"
|
||||
(deftest "nil?"
|
||||
(assert-true (nil? nil))
|
||||
(assert-false (nil? 0))
|
||||
(assert-false (nil? false))
|
||||
(assert-false (nil? "")))
|
||||
|
||||
(deftest "number?"
|
||||
(assert-true (number? 42))
|
||||
(assert-true (number? 3.14))
|
||||
(assert-false (number? "42")))
|
||||
|
||||
(deftest "string?"
|
||||
(assert-true (string? "hello"))
|
||||
(assert-false (string? 42)))
|
||||
|
||||
(deftest "list?"
|
||||
(assert-true (list? (list 1 2)))
|
||||
(assert-false (list? "not a list")))
|
||||
|
||||
(deftest "dict?"
|
||||
(assert-true (dict? {:a 1}))
|
||||
(assert-false (dict? (list 1))))
|
||||
|
||||
(deftest "boolean?"
|
||||
(assert-true (boolean? true))
|
||||
(assert-true (boolean? false))
|
||||
(assert-false (boolean? nil))
|
||||
(assert-false (boolean? 0)))
|
||||
|
||||
(deftest "not"
|
||||
(assert-true (not false))
|
||||
(assert-true (not nil))
|
||||
(assert-false (not true))
|
||||
(assert-false (not 1))
|
||||
(assert-false (not "x"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Special forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "special-forms"
|
||||
(deftest "if"
|
||||
(assert-equal "yes" (if true "yes" "no"))
|
||||
(assert-equal "no" (if false "yes" "no"))
|
||||
(assert-equal "no" (if nil "yes" "no"))
|
||||
(assert-nil (if false "yes")))
|
||||
|
||||
(deftest "when"
|
||||
(assert-equal "yes" (when true "yes"))
|
||||
(assert-nil (when false "yes")))
|
||||
|
||||
(deftest "cond"
|
||||
(assert-equal "a" (cond true "a" :else "b"))
|
||||
(assert-equal "b" (cond false "a" :else "b"))
|
||||
(assert-equal "c" (cond
|
||||
false "a"
|
||||
false "b"
|
||||
:else "c")))
|
||||
|
||||
(deftest "cond with 2-element predicate as first test"
|
||||
;; Regression: cond misclassifies Clojure-style as scheme-style when
|
||||
;; the first test is a 2-element list like (nil? x) or (empty? x).
|
||||
;; The evaluator checks: is first arg a 2-element list? If yes, treats
|
||||
;; as scheme-style ((test body) ...) — returning the arg instead of
|
||||
;; evaluating the predicate call.
|
||||
(assert-equal 0 (cond (nil? nil) 0 :else 1))
|
||||
(assert-equal 1 (cond (nil? "x") 0 :else 1))
|
||||
(assert-equal "empty" (cond (empty? (list)) "empty" :else "not-empty"))
|
||||
(assert-equal "not-empty" (cond (empty? (list 1)) "empty" :else "not-empty"))
|
||||
(assert-equal "yes" (cond (not false) "yes" :else "no"))
|
||||
(assert-equal "no" (cond (not true) "yes" :else "no")))
|
||||
|
||||
(deftest "cond with 2-element predicate and no :else"
|
||||
;; Same bug, but without :else — this is the worst case because the
|
||||
;; bootstrapper heuristic also breaks (all clauses are 2-element lists).
|
||||
(assert-equal "found"
|
||||
(cond (nil? nil) "found"
|
||||
(nil? "x") "other"))
|
||||
(assert-equal "b"
|
||||
(cond (nil? "x") "a"
|
||||
(not false) "b")))
|
||||
|
||||
(deftest "and"
|
||||
(assert-true (and true true))
|
||||
(assert-false (and true false))
|
||||
(assert-false (and false true))
|
||||
(assert-equal 3 (and 1 2 3)))
|
||||
|
||||
(deftest "or"
|
||||
(assert-equal 1 (or 1 2))
|
||||
(assert-equal 2 (or false 2))
|
||||
(assert-equal "fallback" (or nil false "fallback"))
|
||||
(assert-false (or false false)))
|
||||
|
||||
(deftest "let"
|
||||
(assert-equal 3 (let ((x 1) (y 2)) (+ x y)))
|
||||
(assert-equal "hello world"
|
||||
(let ((a "hello") (b " world")) (str a b))))
|
||||
|
||||
(deftest "let clojure-style"
|
||||
(assert-equal 3 (let (x 1 y 2) (+ x y))))
|
||||
|
||||
(deftest "do / begin"
|
||||
(assert-equal 3 (do 1 2 3))
|
||||
(assert-equal "last" (begin "first" "middle" "last")))
|
||||
|
||||
(deftest "define"
|
||||
(define x 42)
|
||||
(assert-equal 42 x))
|
||||
|
||||
(deftest "set!"
|
||||
(define x 1)
|
||||
(set! x 2)
|
||||
(assert-equal 2 x)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Lambda and closures
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "lambdas"
|
||||
(deftest "basic lambda"
|
||||
(let ((add (fn (a b) (+ a b))))
|
||||
(assert-equal 3 (add 1 2))))
|
||||
|
||||
(deftest "closure captures env"
|
||||
(let ((x 10))
|
||||
(let ((add-x (fn (y) (+ x y))))
|
||||
(assert-equal 15 (add-x 5)))))
|
||||
|
||||
(deftest "lambda as argument"
|
||||
(assert-equal (list 2 4 6)
|
||||
(map (fn (x) (* x 2)) (list 1 2 3))))
|
||||
|
||||
(deftest "recursive lambda via define"
|
||||
(define factorial
|
||||
(fn (n) (if (<= n 1) 1 (* n (factorial (- n 1))))))
|
||||
(assert-equal 120 (factorial 5)))
|
||||
|
||||
(deftest "higher-order returns lambda"
|
||||
(let ((make-adder (fn (n) (fn (x) (+ n x)))))
|
||||
(let ((add5 (make-adder 5)))
|
||||
(assert-equal 8 (add5 3)))))
|
||||
|
||||
(deftest "multi-body lambda returns last value"
|
||||
;; All body expressions must execute. Return value is the last.
|
||||
;; Catches: sf-lambda using nth(args,1) instead of rest(args).
|
||||
(let ((f (fn (x) (+ x 1) (+ x 2) (+ x 3))))
|
||||
(assert-equal 13 (f 10))))
|
||||
|
||||
(deftest "multi-body lambda side effects via dict mutation"
|
||||
;; Verify all body expressions run by mutating a shared dict.
|
||||
(let ((state (dict "a" 0 "b" 0)))
|
||||
(let ((f (fn ()
|
||||
(dict-set! state "a" 1)
|
||||
(dict-set! state "b" 2)
|
||||
"done")))
|
||||
(assert-equal "done" (f))
|
||||
(assert-equal 1 (get state "a"))
|
||||
(assert-equal 2 (get state "b")))))
|
||||
|
||||
(deftest "multi-body lambda two expressions"
|
||||
;; Simplest case: two body expressions, return value is second.
|
||||
(assert-equal 20
|
||||
((fn (x) (+ x 1) (* x 2)) 10))
|
||||
;; And with zero-arg lambda
|
||||
(assert-equal 42
|
||||
((fn () (+ 1 2) 42)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Higher-order forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "higher-order"
|
||||
(deftest "map"
|
||||
(assert-equal (list 2 4 6)
|
||||
(map (fn (x) (* x 2)) (list 1 2 3)))
|
||||
(assert-equal (list) (map (fn (x) x) (list))))
|
||||
|
||||
(deftest "filter"
|
||||
(assert-equal (list 2 4)
|
||||
(filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4)))
|
||||
(assert-equal (list)
|
||||
(filter (fn (x) false) (list 1 2 3))))
|
||||
|
||||
(deftest "reduce"
|
||||
(assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4)))
|
||||
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))
|
||||
|
||||
(deftest "some"
|
||||
(assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5)))
|
||||
(assert-false (some (fn (x) (> x 10)) (list 1 2 3))))
|
||||
|
||||
(deftest "every?"
|
||||
(assert-true (every? (fn (x) (> x 0)) (list 1 2 3)))
|
||||
(assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))
|
||||
|
||||
(deftest "map-indexed"
|
||||
(assert-equal (list "0:a" "1:b" "2:c")
|
||||
(map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Components
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "components"
|
||||
(deftest "defcomp creates component"
|
||||
(defcomp ~test-comp (&key title)
|
||||
(div title))
|
||||
(assert-true (not (nil? ~test-comp))))
|
||||
|
||||
(deftest "component renders with keyword args"
|
||||
(defcomp ~greeting (&key name)
|
||||
(span (str "Hello, " name "!")))
|
||||
(assert-true (not (nil? ~greeting))))
|
||||
|
||||
(deftest "component with children"
|
||||
(defcomp ~box (&key &rest children)
|
||||
(div :class "box" children))
|
||||
(assert-true (not (nil? ~box))))
|
||||
|
||||
(deftest "component with default via or"
|
||||
(defcomp ~label (&key text)
|
||||
(span (or text "default")))
|
||||
(assert-true (not (nil? ~label))))
|
||||
|
||||
(deftest "defcomp default affinity is auto"
|
||||
(defcomp ~aff-default (&key x)
|
||||
(div x))
|
||||
(assert-equal "auto" (component-affinity ~aff-default)))
|
||||
|
||||
(deftest "defcomp affinity client"
|
||||
(defcomp ~aff-client (&key x)
|
||||
:affinity :client
|
||||
(div x))
|
||||
(assert-equal "client" (component-affinity ~aff-client)))
|
||||
|
||||
(deftest "defcomp affinity server"
|
||||
(defcomp ~aff-server (&key x)
|
||||
:affinity :server
|
||||
(div x))
|
||||
(assert-equal "server" (component-affinity ~aff-server)))
|
||||
|
||||
(deftest "defcomp affinity preserves body"
|
||||
(defcomp ~aff-body (&key val)
|
||||
:affinity :client
|
||||
(span val))
|
||||
;; Component should still render correctly
|
||||
(assert-equal "client" (component-affinity ~aff-body))
|
||||
(assert-true (not (nil? ~aff-body)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Macros
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "macros"
|
||||
(deftest "defmacro creates macro"
|
||||
(defmacro unless (cond &rest body)
|
||||
`(if (not ,cond) (do ,@body)))
|
||||
(assert-equal "yes" (unless false "yes"))
|
||||
(assert-nil (unless true "no")))
|
||||
|
||||
(deftest "quasiquote and unquote"
|
||||
(let ((x 42))
|
||||
(assert-equal (list 1 42 3) `(1 ,x 3))))
|
||||
|
||||
(deftest "splice-unquote"
|
||||
(let ((xs (list 2 3 4)))
|
||||
(assert-equal (list 1 2 3 4 5) `(1 ,@xs 5)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Threading macro
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "threading"
|
||||
(deftest "thread-first"
|
||||
(assert-equal 8 (-> 5 (+ 1) (+ 2)))
|
||||
(assert-equal "HELLO" (-> "hello" upcase))
|
||||
(assert-equal "HELLO WORLD"
|
||||
(-> "hello"
|
||||
(str " world")
|
||||
upcase))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Truthiness
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "truthiness"
|
||||
(deftest "truthy values"
|
||||
(assert-true (if 1 true false))
|
||||
(assert-true (if "x" true false))
|
||||
(assert-true (if (list 1) true false))
|
||||
(assert-true (if true true false)))
|
||||
|
||||
(deftest "falsy values"
|
||||
(assert-false (if false true false))
|
||||
(assert-false (if nil true false)))
|
||||
|
||||
;; NOTE: empty list, zero, and empty string truthiness is
|
||||
;; platform-dependent. Python treats all three as falsy.
|
||||
;; JavaScript treats [] as truthy but 0 and "" as falsy.
|
||||
;; These tests are omitted — each bootstrapper should emit
|
||||
;; platform-specific truthiness tests instead.
|
||||
)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Edge cases and regression tests
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "edge-cases"
|
||||
(deftest "nested let scoping"
|
||||
(let ((x 1))
|
||||
(let ((x 2))
|
||||
(assert-equal 2 x))
|
||||
;; outer x should be unchanged by inner let
|
||||
;; (this tests that let creates a new scope)
|
||||
))
|
||||
|
||||
(deftest "recursive map"
|
||||
(assert-equal (list (list 2 4) (list 6 8))
|
||||
(map (fn (sub) (map (fn (x) (* x 2)) sub))
|
||||
(list (list 1 2) (list 3 4)))))
|
||||
|
||||
(deftest "keyword as value"
|
||||
(assert-equal "class" :class)
|
||||
(assert-equal "id" :id))
|
||||
|
||||
(deftest "dict with evaluated values"
|
||||
(let ((x 42))
|
||||
(assert-equal 42 (get {:val x} "val"))))
|
||||
|
||||
(deftest "nil propagation"
|
||||
(assert-nil (get {:a 1} "missing"))
|
||||
(assert-equal "default" (or (get {:a 1} "missing") "default")))
|
||||
|
||||
(deftest "empty operations"
|
||||
(assert-equal (list) (map (fn (x) x) (list)))
|
||||
(assert-equal (list) (filter (fn (x) true) (list)))
|
||||
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list)))
|
||||
(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?)
|
||||
86
spec/test-framework.sx
Normal file
86
spec/test-framework.sx
Normal file
@@ -0,0 +1,86 @@
|
||||
;; ==========================================================================
|
||||
;; test-framework.sx — Reusable test macros and assertion helpers
|
||||
;;
|
||||
;; Loaded first by all test runners. Provides deftest, defsuite, and
|
||||
;; assertion helpers. Requires 5 platform functions from the host:
|
||||
;;
|
||||
;; try-call (thunk) -> {:ok true} | {:ok false :error "msg"}
|
||||
;; report-pass (name) -> platform-specific pass output
|
||||
;; report-fail (name error) -> platform-specific fail output
|
||||
;; push-suite (name) -> push suite name onto context stack
|
||||
;; pop-suite () -> pop suite name from context stack
|
||||
;;
|
||||
;; Any host that provides these 5 functions can run any test spec.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Test framework macros
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defmacro deftest (name &rest body)
|
||||
`(let ((result (try-call (fn () ,@body))))
|
||||
(if (get result "ok")
|
||||
(report-pass ,name)
|
||||
(report-fail ,name (get result "error")))))
|
||||
|
||||
(defmacro defsuite (name &rest items)
|
||||
`(do (push-suite ,name)
|
||||
,@items
|
||||
(pop-suite)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Assertion helpers — defined in SX, available in test bodies
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define assert-equal
|
||||
(fn (expected actual)
|
||||
(assert (equal? expected actual)
|
||||
(str "Expected " (str expected) " but got " (str actual)))))
|
||||
|
||||
(define assert-not-equal
|
||||
(fn (a b)
|
||||
(assert (not (equal? a b))
|
||||
(str "Expected values to differ but both are " (str a)))))
|
||||
|
||||
(define assert-true
|
||||
(fn (val)
|
||||
(assert val (str "Expected truthy but got " (str val)))))
|
||||
|
||||
(define assert-false
|
||||
(fn (val)
|
||||
(assert (not val) (str "Expected falsy but got " (str val)))))
|
||||
|
||||
(define assert-nil
|
||||
(fn (val)
|
||||
(assert (nil? val) (str "Expected nil but got " (str val)))))
|
||||
|
||||
(define assert-type
|
||||
(fn ((expected-type :as string) val)
|
||||
(let ((actual-type
|
||||
(if (nil? val) "nil"
|
||||
(if (boolean? val) "boolean"
|
||||
(if (number? val) "number"
|
||||
(if (string? val) "string"
|
||||
(if (list? val) "list"
|
||||
(if (dict? val) "dict"
|
||||
"unknown"))))))))
|
||||
(assert (= expected-type actual-type)
|
||||
(str "Expected type " expected-type " but got " actual-type)))))
|
||||
|
||||
(define assert-length
|
||||
(fn ((expected-len :as number) (col :as list))
|
||||
(assert (= (len col) expected-len)
|
||||
(str "Expected length " expected-len " but got " (len col)))))
|
||||
|
||||
(define assert-contains
|
||||
(fn (item (col :as list))
|
||||
(assert (some (fn (x) (equal? x item)) col)
|
||||
(str "Expected collection to contain " (str item)))))
|
||||
|
||||
(define assert-throws
|
||||
(fn ((thunk :as lambda))
|
||||
(let ((result (try-call thunk)))
|
||||
(assert (not (get result "ok"))
|
||||
"Expected an error to be thrown but none was"))))
|
||||
259
spec/test-parser.sx
Normal file
259
spec/test-parser.sx
Normal file
@@ -0,0 +1,259 @@
|
||||
;; ==========================================================================
|
||||
;; test-parser.sx — Tests for the SX parser and serializer
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: parser.sx
|
||||
;;
|
||||
;; Platform functions required (beyond test framework):
|
||||
;; sx-parse (source) -> list of AST expressions
|
||||
;; sx-serialize (expr) -> SX source string
|
||||
;; make-symbol (name) -> Symbol value
|
||||
;; make-keyword (name) -> Keyword value
|
||||
;; symbol-name (sym) -> string
|
||||
;; keyword-name (kw) -> string
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Literal parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parser-literals"
|
||||
(deftest "parse integers"
|
||||
(assert-equal (list 42) (sx-parse "42"))
|
||||
(assert-equal (list 0) (sx-parse "0"))
|
||||
(assert-equal (list -7) (sx-parse "-7")))
|
||||
|
||||
(deftest "parse floats"
|
||||
(assert-equal (list 3.14) (sx-parse "3.14"))
|
||||
(assert-equal (list -0.5) (sx-parse "-0.5")))
|
||||
|
||||
(deftest "parse strings"
|
||||
(assert-equal (list "hello") (sx-parse "\"hello\""))
|
||||
(assert-equal (list "") (sx-parse "\"\"")))
|
||||
|
||||
(deftest "parse escape: newline"
|
||||
(assert-equal (list "a\nb") (sx-parse "\"a\\nb\"")))
|
||||
|
||||
(deftest "parse escape: tab"
|
||||
(assert-equal (list "a\tb") (sx-parse "\"a\\tb\"")))
|
||||
|
||||
(deftest "parse escape: quote"
|
||||
(assert-equal (list "a\"b") (sx-parse "\"a\\\"b\"")))
|
||||
|
||||
(deftest "parse booleans"
|
||||
(assert-equal (list true) (sx-parse "true"))
|
||||
(assert-equal (list false) (sx-parse "false")))
|
||||
|
||||
(deftest "parse nil"
|
||||
(assert-equal (list nil) (sx-parse "nil")))
|
||||
|
||||
(deftest "parse keywords"
|
||||
(let ((result (sx-parse ":hello")))
|
||||
(assert-length 1 result)
|
||||
(assert-equal "hello" (keyword-name (first result)))))
|
||||
|
||||
(deftest "parse symbols"
|
||||
(let ((result (sx-parse "foo")))
|
||||
(assert-length 1 result)
|
||||
(assert-equal "foo" (symbol-name (first result))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Composite parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parser-lists"
|
||||
(deftest "parse empty list"
|
||||
(let ((result (sx-parse "()")))
|
||||
(assert-length 1 result)
|
||||
(assert-equal (list) (first result))))
|
||||
|
||||
(deftest "parse list of numbers"
|
||||
(let ((result (sx-parse "(1 2 3)")))
|
||||
(assert-length 1 result)
|
||||
(assert-equal (list 1 2 3) (first result))))
|
||||
|
||||
(deftest "parse nested lists"
|
||||
(let ((result (sx-parse "(1 (2 3) 4)")))
|
||||
(assert-length 1 result)
|
||||
(assert-equal (list 1 (list 2 3) 4) (first result))))
|
||||
|
||||
(deftest "parse square brackets as list"
|
||||
(let ((result (sx-parse "[1 2 3]")))
|
||||
(assert-length 1 result)
|
||||
(assert-equal (list 1 2 3) (first result))))
|
||||
|
||||
(deftest "parse mixed types"
|
||||
(let ((result (sx-parse "(42 \"hello\" true nil)")))
|
||||
(assert-length 1 result)
|
||||
(let ((lst (first result)))
|
||||
(assert-equal 42 (nth lst 0))
|
||||
(assert-equal "hello" (nth lst 1))
|
||||
(assert-equal true (nth lst 2))
|
||||
(assert-nil (nth lst 3))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Dict parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parser-dicts"
|
||||
(deftest "parse empty dict"
|
||||
(let ((result (sx-parse "{}")))
|
||||
(assert-length 1 result)
|
||||
(assert-type "dict" (first result))))
|
||||
|
||||
(deftest "parse dict with keyword keys"
|
||||
(let ((result (sx-parse "{:a 1 :b 2}")))
|
||||
(assert-length 1 result)
|
||||
(let ((d (first result)))
|
||||
(assert-type "dict" d)
|
||||
(assert-equal 1 (get d "a"))
|
||||
(assert-equal 2 (get d "b")))))
|
||||
|
||||
(deftest "parse dict with string values"
|
||||
(let ((result (sx-parse "{:name \"alice\"}")))
|
||||
(assert-length 1 result)
|
||||
(assert-equal "alice" (get (first result) "name")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Comments and whitespace
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parser-whitespace"
|
||||
(deftest "skip line comments"
|
||||
(assert-equal (list 42) (sx-parse ";; comment\n42"))
|
||||
(assert-equal (list 1 2) (sx-parse "1 ;; middle\n2")))
|
||||
|
||||
(deftest "skip whitespace"
|
||||
(assert-equal (list 42) (sx-parse " 42 "))
|
||||
(assert-equal (list 1 2) (sx-parse " 1 \n\t 2 ")))
|
||||
|
||||
(deftest "parse multiple top-level expressions"
|
||||
(assert-length 3 (sx-parse "1 2 3"))
|
||||
(assert-equal (list 1 2 3) (sx-parse "1 2 3")))
|
||||
|
||||
(deftest "empty input"
|
||||
(assert-equal (list) (sx-parse "")))
|
||||
|
||||
(deftest "only comments"
|
||||
(assert-equal (list) (sx-parse ";; just a comment\n;; another"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Quote sugar
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parser-quote-sugar"
|
||||
(deftest "quasiquote"
|
||||
(let ((result (sx-parse "`foo")))
|
||||
(assert-length 1 result)
|
||||
(let ((expr (first result)))
|
||||
(assert-type "list" expr)
|
||||
(assert-equal "quasiquote" (symbol-name (first expr))))))
|
||||
|
||||
(deftest "unquote"
|
||||
(let ((result (sx-parse ",foo")))
|
||||
(assert-length 1 result)
|
||||
(let ((expr (first result)))
|
||||
(assert-type "list" expr)
|
||||
(assert-equal "unquote" (symbol-name (first expr))))))
|
||||
|
||||
(deftest "splice-unquote"
|
||||
(let ((result (sx-parse ",@foo")))
|
||||
(assert-length 1 result)
|
||||
(let ((expr (first result)))
|
||||
(assert-type "list" expr)
|
||||
(assert-equal "splice-unquote" (symbol-name (first expr)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Serializer
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "serializer"
|
||||
(deftest "serialize number"
|
||||
(assert-equal "42" (sx-serialize 42)))
|
||||
|
||||
(deftest "serialize string"
|
||||
(assert-equal "\"hello\"" (sx-serialize "hello")))
|
||||
|
||||
(deftest "serialize boolean"
|
||||
(assert-equal "true" (sx-serialize true))
|
||||
(assert-equal "false" (sx-serialize false)))
|
||||
|
||||
(deftest "serialize nil"
|
||||
(assert-equal "nil" (sx-serialize nil)))
|
||||
|
||||
(deftest "serialize keyword"
|
||||
(assert-equal ":foo" (sx-serialize (make-keyword "foo"))))
|
||||
|
||||
(deftest "serialize symbol"
|
||||
(assert-equal "bar" (sx-serialize (make-symbol "bar"))))
|
||||
|
||||
(deftest "serialize list"
|
||||
(assert-equal "(1 2 3)" (sx-serialize (list 1 2 3))))
|
||||
|
||||
(deftest "serialize empty list"
|
||||
(assert-equal "()" (sx-serialize (list))))
|
||||
|
||||
(deftest "serialize nested"
|
||||
(assert-equal "(1 (2 3) 4)" (sx-serialize (list 1 (list 2 3) 4)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Round-trip: parse then serialize
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parser-roundtrip"
|
||||
(deftest "roundtrip number"
|
||||
(assert-equal "42" (sx-serialize (first (sx-parse "42")))))
|
||||
|
||||
(deftest "roundtrip string"
|
||||
(assert-equal "\"hello\"" (sx-serialize (first (sx-parse "\"hello\"")))))
|
||||
|
||||
(deftest "roundtrip list"
|
||||
(assert-equal "(1 2 3)" (sx-serialize (first (sx-parse "(1 2 3)")))))
|
||||
|
||||
(deftest "roundtrip nested"
|
||||
(assert-equal "(a (b c))"
|
||||
(sx-serialize (first (sx-parse "(a (b c))"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Reader macros
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "reader-macros"
|
||||
(deftest "datum comment discards expr"
|
||||
(assert-equal (list 42) (sx-parse "#;(ignored) 42")))
|
||||
|
||||
(deftest "datum comment in list"
|
||||
(assert-equal (list (list 1 3)) (sx-parse "(1 #;2 3)")))
|
||||
|
||||
(deftest "datum comment discards nested"
|
||||
(assert-equal (list 99) (sx-parse "#;(a (b c) d) 99")))
|
||||
|
||||
(deftest "raw string basic"
|
||||
(assert-equal (list "hello") (sx-parse "#|hello|")))
|
||||
|
||||
(deftest "raw string with quotes"
|
||||
(assert-equal (list "say \"hi\"") (sx-parse "#|say \"hi\"|")))
|
||||
|
||||
(deftest "raw string with backslashes"
|
||||
(assert-equal (list "a\\nb") (sx-parse "#|a\\nb|")))
|
||||
|
||||
(deftest "raw string empty"
|
||||
(assert-equal (list "") (sx-parse "#||")))
|
||||
|
||||
(deftest "quote shorthand symbol"
|
||||
(let ((result (first (sx-parse "#'foo"))))
|
||||
(assert-equal "quote" (symbol-name (first result)))
|
||||
(assert-equal "foo" (symbol-name (nth result 1)))))
|
||||
|
||||
(deftest "quote shorthand list"
|
||||
(let ((result (first (sx-parse "#'(1 2 3)"))))
|
||||
(assert-equal "quote" (symbol-name (first result)))
|
||||
(assert-equal (list 1 2 3) (nth result 1)))))
|
||||
230
spec/test-render.sx
Normal file
230
spec/test-render.sx
Normal file
@@ -0,0 +1,230 @@
|
||||
;; ==========================================================================
|
||||
;; test-render.sx — Tests for the HTML rendering adapter
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: render.sx, adapter-html.sx
|
||||
;;
|
||||
;; Platform functions required (beyond test framework):
|
||||
;; render-html (sx-source) -> HTML string
|
||||
;; Parses the sx-source string, evaluates via render-to-html in a
|
||||
;; fresh env, and returns the resulting HTML string.
|
||||
;; (This is a test-only convenience that wraps parse + render-to-html.)
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Basic element rendering
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-elements"
|
||||
(deftest "simple div"
|
||||
(assert-equal "<div>hello</div>"
|
||||
(render-html "(div \"hello\")")))
|
||||
|
||||
(deftest "nested elements"
|
||||
(assert-equal "<div><span>hi</span></div>"
|
||||
(render-html "(div (span \"hi\"))")))
|
||||
|
||||
(deftest "multiple children"
|
||||
(assert-equal "<div><p>a</p><p>b</p></div>"
|
||||
(render-html "(div (p \"a\") (p \"b\"))")))
|
||||
|
||||
(deftest "text content"
|
||||
(assert-equal "<p>hello world</p>"
|
||||
(render-html "(p \"hello\" \" world\")")))
|
||||
|
||||
(deftest "number content"
|
||||
(assert-equal "<span>42</span>"
|
||||
(render-html "(span 42)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Attributes
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-attrs"
|
||||
(deftest "string attribute"
|
||||
(let ((html (render-html "(div :id \"main\" \"content\")")))
|
||||
(assert-true (string-contains? html "id=\"main\""))
|
||||
(assert-true (string-contains? html "content"))))
|
||||
|
||||
(deftest "class attribute"
|
||||
(let ((html (render-html "(div :class \"foo bar\" \"x\")")))
|
||||
(assert-true (string-contains? html "class=\"foo bar\""))))
|
||||
|
||||
(deftest "multiple attributes"
|
||||
(let ((html (render-html "(a :href \"/home\" :class \"link\" \"Home\")")))
|
||||
(assert-true (string-contains? html "href=\"/home\""))
|
||||
(assert-true (string-contains? html "class=\"link\""))
|
||||
(assert-true (string-contains? html "Home")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Void elements
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-void"
|
||||
(deftest "br is self-closing"
|
||||
(assert-equal "<br />" (render-html "(br)")))
|
||||
|
||||
(deftest "img with attrs"
|
||||
(let ((html (render-html "(img :src \"pic.jpg\" :alt \"A pic\")")))
|
||||
(assert-true (string-contains? html "<img"))
|
||||
(assert-true (string-contains? html "src=\"pic.jpg\""))
|
||||
(assert-true (string-contains? html "/>"))
|
||||
;; void elements should not have a closing tag
|
||||
(assert-false (string-contains? html "</img>"))))
|
||||
|
||||
(deftest "input is self-closing"
|
||||
(let ((html (render-html "(input :type \"text\" :name \"q\")")))
|
||||
(assert-true (string-contains? html "<input"))
|
||||
(assert-true (string-contains? html "/>")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Boolean attributes
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-boolean-attrs"
|
||||
(deftest "true boolean attr emits name only"
|
||||
(let ((html (render-html "(input :disabled true :type \"text\")")))
|
||||
(assert-true (string-contains? html "disabled"))
|
||||
;; Should NOT have disabled="true"
|
||||
(assert-false (string-contains? html "disabled=\""))))
|
||||
|
||||
(deftest "false boolean attr omitted"
|
||||
(let ((html (render-html "(input :disabled false :type \"text\")")))
|
||||
(assert-false (string-contains? html "disabled")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Fragments
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-fragments"
|
||||
(deftest "fragment renders children without wrapper"
|
||||
(assert-equal "<p>a</p><p>b</p>"
|
||||
(render-html "(<> (p \"a\") (p \"b\"))")))
|
||||
|
||||
(deftest "empty fragment"
|
||||
(assert-equal ""
|
||||
(render-html "(<>)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; HTML escaping
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-escaping"
|
||||
(deftest "text content is escaped"
|
||||
(let ((html (render-html "(p \"<script>alert(1)</script>\")")))
|
||||
(assert-false (string-contains? html "<script>"))
|
||||
(assert-true (string-contains? html "<script>"))))
|
||||
|
||||
(deftest "attribute values are escaped"
|
||||
(let ((html (render-html "(div :title \"a\\\"b\" \"x\")")))
|
||||
(assert-true (string-contains? html "title=")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Control flow in render context
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-control-flow"
|
||||
(deftest "if renders correct branch"
|
||||
(assert-equal "<p>yes</p>"
|
||||
(render-html "(if true (p \"yes\") (p \"no\"))"))
|
||||
(assert-equal "<p>no</p>"
|
||||
(render-html "(if false (p \"yes\") (p \"no\"))")))
|
||||
|
||||
(deftest "when renders or skips"
|
||||
(assert-equal "<p>ok</p>"
|
||||
(render-html "(when true (p \"ok\"))"))
|
||||
(assert-equal ""
|
||||
(render-html "(when false (p \"ok\"))")))
|
||||
|
||||
(deftest "map renders list"
|
||||
(assert-equal "<li>1</li><li>2</li><li>3</li>"
|
||||
(render-html "(map (fn (x) (li x)) (list 1 2 3))")))
|
||||
|
||||
(deftest "let in render context"
|
||||
(assert-equal "<p>hello</p>"
|
||||
(render-html "(let ((x \"hello\")) (p x))")))
|
||||
|
||||
(deftest "cond with 2-element predicate test"
|
||||
;; Regression: cond misclassifies (nil? x) as scheme-style clause.
|
||||
(assert-equal "<p>yes</p>"
|
||||
(render-html "(cond (nil? nil) (p \"yes\") :else (p \"no\"))"))
|
||||
(assert-equal "<p>no</p>"
|
||||
(render-html "(cond (nil? \"x\") (p \"yes\") :else (p \"no\"))")))
|
||||
|
||||
(deftest "let preserves outer scope bindings"
|
||||
;; Regression: process-bindings must preserve parent env scope chain.
|
||||
;; Using merge() on Env objects returns empty dict (Env is not dict subclass).
|
||||
(assert-equal "<p>outer</p>"
|
||||
(render-html "(do (define theme \"outer\") (let ((x 1)) (p theme)))")))
|
||||
|
||||
(deftest "nested let preserves outer scope"
|
||||
(assert-equal "<div><span>hello</span><span>world</span></div>"
|
||||
(render-html "(do (define a \"hello\")
|
||||
(define b \"world\")
|
||||
(div (let ((x 1)) (span a))
|
||||
(let ((y 2)) (span b))))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Component rendering
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-components"
|
||||
(deftest "component with keyword args"
|
||||
(assert-equal "<h1>Hello</h1>"
|
||||
(render-html "(do (defcomp ~title (&key text) (h1 text)) (~title :text \"Hello\"))")))
|
||||
|
||||
(deftest "component with children"
|
||||
(let ((html (render-html "(do (defcomp ~box (&key &rest children) (div :class \"box\" children)) (~box (p \"inside\")))")))
|
||||
(assert-true (string-contains? html "class=\"box\""))
|
||||
(assert-true (string-contains? html "<p>inside</p>")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Map/filter producing multiple children (aser-adjacent regression tests)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-map-children"
|
||||
(deftest "map producing multiple children inside tag"
|
||||
(assert-equal "<ul><li>a</li><li>b</li><li>c</li></ul>"
|
||||
(render-html "(do (define items (list \"a\" \"b\" \"c\"))
|
||||
(ul (map (fn (x) (li x)) items)))")))
|
||||
|
||||
(deftest "map with other siblings"
|
||||
(assert-equal "<ul><li>first</li><li>a</li><li>b</li></ul>"
|
||||
(render-html "(do (define items (list \"a\" \"b\"))
|
||||
(ul (li \"first\") (map (fn (x) (li x)) items)))")))
|
||||
|
||||
(deftest "filter with nil results inside tag"
|
||||
(assert-equal "<ul><li>a</li><li>c</li></ul>"
|
||||
(render-html "(do (define items (list \"a\" nil \"c\"))
|
||||
(ul (map (fn (x) (li x))
|
||||
(filter (fn (x) (not (nil? x))) items))))")))
|
||||
|
||||
(deftest "nested map inside let"
|
||||
(assert-equal "<div><span>1</span><span>2</span></div>"
|
||||
(render-html "(let ((nums (list 1 2)))
|
||||
(div (map (fn (n) (span n)) nums)))")))
|
||||
|
||||
(deftest "component with &rest receiving mapped results"
|
||||
(let ((html (render-html "(do (defcomp ~list-box (&key &rest children) (div :class \"lb\" children))
|
||||
(define items (list \"x\" \"y\"))
|
||||
(~list-box (map (fn (x) (p x)) items)))")))
|
||||
(assert-true (string-contains? html "class=\"lb\""))
|
||||
(assert-true (string-contains? html "<p>x</p>"))
|
||||
(assert-true (string-contains? html "<p>y</p>"))))
|
||||
|
||||
(deftest "map-indexed renders with index"
|
||||
(assert-equal "<li>0: a</li><li>1: b</li>"
|
||||
(render-html "(map-indexed (fn (i x) (li (str i \": \" x))) (list \"a\" \"b\"))")))
|
||||
|
||||
(deftest "for-each renders each item"
|
||||
(assert-equal "<p>1</p><p>2</p>"
|
||||
(render-html "(for-each (fn (x) (p x)) (list 1 2))"))))
|
||||
652
spec/test-types.sx
Normal file
652
spec/test-types.sx
Normal file
@@ -0,0 +1,652 @@
|
||||
;; ==========================================================================
|
||||
;; test-types.sx — Tests for the SX gradual type system
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: types.sx (subtype?, infer-type, check-component, etc.)
|
||||
;;
|
||||
;; Platform functions required (beyond test framework):
|
||||
;; All type system functions from types.sx must be loaded.
|
||||
;; test-prim-types — a dict of primitive return types for testing.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Subtype checking
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "subtype-basics"
|
||||
(deftest "any accepts everything"
|
||||
(assert-true (subtype? "number" "any"))
|
||||
(assert-true (subtype? "string" "any"))
|
||||
(assert-true (subtype? "nil" "any"))
|
||||
(assert-true (subtype? "boolean" "any"))
|
||||
(assert-true (subtype? "any" "any")))
|
||||
|
||||
(deftest "never is subtype of everything"
|
||||
(assert-true (subtype? "never" "number"))
|
||||
(assert-true (subtype? "never" "string"))
|
||||
(assert-true (subtype? "never" "any"))
|
||||
(assert-true (subtype? "never" "nil")))
|
||||
|
||||
(deftest "identical types"
|
||||
(assert-true (subtype? "number" "number"))
|
||||
(assert-true (subtype? "string" "string"))
|
||||
(assert-true (subtype? "boolean" "boolean"))
|
||||
(assert-true (subtype? "nil" "nil")))
|
||||
|
||||
(deftest "different base types are not subtypes"
|
||||
(assert-false (subtype? "number" "string"))
|
||||
(assert-false (subtype? "string" "number"))
|
||||
(assert-false (subtype? "boolean" "number"))
|
||||
(assert-false (subtype? "string" "boolean")))
|
||||
|
||||
(deftest "any is not subtype of specific type"
|
||||
(assert-false (subtype? "any" "number"))
|
||||
(assert-false (subtype? "any" "string"))))
|
||||
|
||||
|
||||
(defsuite "subtype-nullable"
|
||||
(deftest "nil is subtype of nullable types"
|
||||
(assert-true (subtype? "nil" "string?"))
|
||||
(assert-true (subtype? "nil" "number?"))
|
||||
(assert-true (subtype? "nil" "dict?"))
|
||||
(assert-true (subtype? "nil" "boolean?")))
|
||||
|
||||
(deftest "base is subtype of its nullable"
|
||||
(assert-true (subtype? "string" "string?"))
|
||||
(assert-true (subtype? "number" "number?"))
|
||||
(assert-true (subtype? "dict" "dict?")))
|
||||
|
||||
(deftest "nullable is not subtype of base"
|
||||
(assert-false (subtype? "string?" "string"))
|
||||
(assert-false (subtype? "number?" "number")))
|
||||
|
||||
(deftest "different nullable types are not subtypes"
|
||||
(assert-false (subtype? "number" "string?"))
|
||||
(assert-false (subtype? "string" "number?"))))
|
||||
|
||||
|
||||
(defsuite "subtype-unions"
|
||||
(deftest "member is subtype of union"
|
||||
(assert-true (subtype? "number" (list "or" "number" "string")))
|
||||
(assert-true (subtype? "string" (list "or" "number" "string"))))
|
||||
|
||||
(deftest "non-member is not subtype of union"
|
||||
(assert-false (subtype? "boolean" (list "or" "number" "string"))))
|
||||
|
||||
(deftest "union is subtype if all members are"
|
||||
(assert-true (subtype? (list "or" "number" "string")
|
||||
(list "or" "number" "string" "boolean")))
|
||||
(assert-true (subtype? (list "or" "number" "string") "any")))
|
||||
|
||||
(deftest "union is not subtype if any member is not"
|
||||
(assert-false (subtype? (list "or" "number" "string") "number"))))
|
||||
|
||||
|
||||
(defsuite "subtype-list-of"
|
||||
(deftest "list-of covariance"
|
||||
(assert-true (subtype? (list "list-of" "number") (list "list-of" "number")))
|
||||
(assert-true (subtype? (list "list-of" "number") (list "list-of" "any"))))
|
||||
|
||||
(deftest "list-of is subtype of list"
|
||||
(assert-true (subtype? (list "list-of" "number") "list")))
|
||||
|
||||
(deftest "list is subtype of list-of any"
|
||||
(assert-true (subtype? "list" (list "list-of" "any")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Type union
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "type-union"
|
||||
(deftest "same types"
|
||||
(assert-equal "number" (type-union "number" "number"))
|
||||
(assert-equal "string" (type-union "string" "string")))
|
||||
|
||||
(deftest "any absorbs"
|
||||
(assert-equal "any" (type-union "any" "number"))
|
||||
(assert-equal "any" (type-union "number" "any")))
|
||||
|
||||
(deftest "never is identity"
|
||||
(assert-equal "number" (type-union "never" "number"))
|
||||
(assert-equal "string" (type-union "string" "never")))
|
||||
|
||||
(deftest "nil + base creates nullable"
|
||||
(assert-equal "string?" (type-union "nil" "string"))
|
||||
(assert-equal "number?" (type-union "number" "nil")))
|
||||
|
||||
(deftest "subtype collapses"
|
||||
(assert-equal "string?" (type-union "string" "string?"))
|
||||
(assert-equal "string?" (type-union "string?" "string")))
|
||||
|
||||
(deftest "incompatible creates union"
|
||||
(let ((result (type-union "number" "string")))
|
||||
(assert-true (= (type-of result) "list"))
|
||||
(assert-equal "or" (first result))
|
||||
(assert-true (contains? result "number"))
|
||||
(assert-true (contains? result "string")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Type narrowing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "type-narrowing"
|
||||
(deftest "nil? narrows to nil in then branch"
|
||||
(let ((result (narrow-type "string?" "nil?")))
|
||||
(assert-equal "nil" (first result))
|
||||
(assert-equal "string" (nth result 1))))
|
||||
|
||||
(deftest "nil? narrows any stays any"
|
||||
(let ((result (narrow-type "any" "nil?")))
|
||||
(assert-equal "nil" (first result))
|
||||
(assert-equal "any" (nth result 1))))
|
||||
|
||||
(deftest "string? narrows to string in then branch"
|
||||
(let ((result (narrow-type "any" "string?")))
|
||||
(assert-equal "string" (first result))
|
||||
;; else branch — can't narrow any
|
||||
(assert-equal "any" (nth result 1))))
|
||||
|
||||
(deftest "nil? on nil type narrows to never in else"
|
||||
(let ((result (narrow-type "nil" "nil?")))
|
||||
(assert-equal "nil" (first result))
|
||||
(assert-equal "never" (nth result 1)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Type inference
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "infer-literals"
|
||||
(deftest "number literal"
|
||||
(assert-equal "number" (infer-type 42 (dict) (test-prim-types))))
|
||||
|
||||
(deftest "string literal"
|
||||
(assert-equal "string" (infer-type "hello" (dict) (test-prim-types))))
|
||||
|
||||
(deftest "boolean literal"
|
||||
(assert-equal "boolean" (infer-type true (dict) (test-prim-types))))
|
||||
|
||||
(deftest "nil"
|
||||
(assert-equal "nil" (infer-type nil (dict) (test-prim-types)))))
|
||||
|
||||
|
||||
(defsuite "infer-calls"
|
||||
(deftest "known primitive return type"
|
||||
;; (+ 1 2) → number
|
||||
(let ((expr (sx-parse "(+ 1 2)")))
|
||||
(assert-equal "number"
|
||||
(infer-type (first expr) (dict) (test-prim-types)))))
|
||||
|
||||
(deftest "str returns string"
|
||||
(let ((expr (sx-parse "(str 1 2)")))
|
||||
(assert-equal "string"
|
||||
(infer-type (first expr) (dict) (test-prim-types)))))
|
||||
|
||||
(deftest "comparison returns boolean"
|
||||
(let ((expr (sx-parse "(= 1 2)")))
|
||||
(assert-equal "boolean"
|
||||
(infer-type (first expr) (dict) (test-prim-types)))))
|
||||
|
||||
(deftest "component call returns element"
|
||||
(let ((expr (sx-parse "(~card :title \"hi\")")))
|
||||
(assert-equal "element"
|
||||
(infer-type (first expr) (dict) (test-prim-types)))))
|
||||
|
||||
(deftest "unknown function returns any"
|
||||
(let ((expr (sx-parse "(unknown-fn 1 2)")))
|
||||
(assert-equal "any"
|
||||
(infer-type (first expr) (dict) (test-prim-types))))))
|
||||
|
||||
|
||||
(defsuite "infer-special-forms"
|
||||
(deftest "if produces union of branches"
|
||||
(let ((expr (sx-parse "(if true 42 \"hello\")")))
|
||||
(let ((t (infer-type (first expr) (dict) (test-prim-types))))
|
||||
;; number | string — should be a union
|
||||
(assert-true (or (= t (list "or" "number" "string"))
|
||||
(= t "any"))))))
|
||||
|
||||
(deftest "if with no else includes nil"
|
||||
(let ((expr (sx-parse "(if true 42)")))
|
||||
(let ((t (infer-type (first expr) (dict) (test-prim-types))))
|
||||
(assert-equal "number?" t))))
|
||||
|
||||
(deftest "when includes nil"
|
||||
(let ((expr (sx-parse "(when true 42)")))
|
||||
(let ((t (infer-type (first expr) (dict) (test-prim-types))))
|
||||
(assert-equal "number?" t))))
|
||||
|
||||
(deftest "do returns last type"
|
||||
(let ((expr (sx-parse "(do 1 2 \"hello\")")))
|
||||
(assert-equal "string"
|
||||
(infer-type (first expr) (dict) (test-prim-types)))))
|
||||
|
||||
(deftest "let infers binding types"
|
||||
(let ((expr (sx-parse "(let ((x 42)) x)")))
|
||||
(assert-equal "number"
|
||||
(infer-type (first expr) (dict) (test-prim-types)))))
|
||||
|
||||
(deftest "lambda returns lambda"
|
||||
(let ((expr (sx-parse "(fn (x) (+ x 1))")))
|
||||
(assert-equal "lambda"
|
||||
(infer-type (first expr) (dict) (test-prim-types))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Component call checking
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "check-component-calls"
|
||||
(deftest "type mismatch produces error"
|
||||
;; Create a component with typed params, then check a bad call
|
||||
(let ((env (test-env)))
|
||||
;; Define a typed component
|
||||
(do
|
||||
(define dummy-env env)
|
||||
(defcomp ~typed-card (&key title price) (div title price))
|
||||
(component-set-param-types! ~typed-card
|
||||
{:title "string" :price "number"}))
|
||||
;; Check a call with wrong type
|
||||
(let ((diagnostics
|
||||
(check-component-call "~typed-card" ~typed-card
|
||||
(rest (first (sx-parse "(~typed-card :title 42 :price \"bad\")")))
|
||||
(dict) (test-prim-types))))
|
||||
(assert-true (> (len diagnostics) 0))
|
||||
(assert-equal "error" (dict-get (first diagnostics) "level")))))
|
||||
|
||||
(deftest "correct call produces no errors"
|
||||
(let ((env (test-env)))
|
||||
(do
|
||||
(define dummy-env env)
|
||||
(defcomp ~ok-card (&key title price) (div title price))
|
||||
(component-set-param-types! ~ok-card
|
||||
{:title "string" :price "number"}))
|
||||
(let ((diagnostics
|
||||
(check-component-call "~ok-card" ~ok-card
|
||||
(rest (first (sx-parse "(~ok-card :title \"hi\" :price 42)")))
|
||||
(dict) (test-prim-types))))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
|
||||
(deftest "unknown kwarg produces warning"
|
||||
(let ((env (test-env)))
|
||||
(do
|
||||
(define dummy-env env)
|
||||
(defcomp ~warn-card (&key title) (div title))
|
||||
(component-set-param-types! ~warn-card
|
||||
{:title "string"}))
|
||||
(let ((diagnostics
|
||||
(check-component-call "~warn-card" ~warn-card
|
||||
(rest (first (sx-parse "(~warn-card :title \"hi\" :colour \"red\")")))
|
||||
(dict) (test-prim-types))))
|
||||
(assert-true (> (len diagnostics) 0))
|
||||
(assert-equal "warning" (dict-get (first diagnostics) "level"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Annotation syntax: (name :as type) in defcomp params
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "typed-defcomp"
|
||||
(deftest "typed params are parsed and stored"
|
||||
(let ((env (test-env)))
|
||||
(defcomp ~typed-widget (&key (title :as string) (count :as number)) (div title count))
|
||||
(let ((pt (component-param-types ~typed-widget)))
|
||||
(assert-true (not (nil? pt)))
|
||||
(assert-equal "string" (dict-get pt "title"))
|
||||
(assert-equal "number" (dict-get pt "count")))))
|
||||
|
||||
(deftest "mixed typed and untyped params"
|
||||
(let ((env (test-env)))
|
||||
(defcomp ~mixed-widget (&key (title :as string) subtitle) (div title subtitle))
|
||||
(let ((pt (component-param-types ~mixed-widget)))
|
||||
(assert-true (not (nil? pt)))
|
||||
(assert-equal "string" (dict-get pt "title"))
|
||||
;; subtitle has no annotation — should not be in param-types
|
||||
(assert-false (has-key? pt "subtitle")))))
|
||||
|
||||
(deftest "untyped defcomp has nil param-types"
|
||||
(let ((env (test-env)))
|
||||
(defcomp ~plain-widget (&key title subtitle) (div title subtitle))
|
||||
(assert-true (nil? (component-param-types ~plain-widget)))))
|
||||
|
||||
(deftest "typed component catches type error on call"
|
||||
(let ((env (test-env)))
|
||||
(defcomp ~strict-card (&key (title :as string) (price :as number)) (div title price))
|
||||
;; Call with wrong types
|
||||
(let ((diagnostics
|
||||
(check-component-call "~strict-card" ~strict-card
|
||||
(rest (first (sx-parse "(~strict-card :title 42 :price \"bad\")")))
|
||||
(dict) (test-prim-types))))
|
||||
;; Should have errors for both wrong-type args
|
||||
(assert-true (>= (len diagnostics) 1))
|
||||
(assert-equal "error" (dict-get (first diagnostics) "level")))))
|
||||
|
||||
(deftest "typed component passes correct call"
|
||||
(let ((env (test-env)))
|
||||
(defcomp ~ok-widget (&key (name :as string) (age :as number)) (div name age))
|
||||
(let ((diagnostics
|
||||
(check-component-call "~ok-widget" ~ok-widget
|
||||
(rest (first (sx-parse "(~ok-widget :name \"Alice\" :age 30)")))
|
||||
(dict) (test-prim-types))))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
|
||||
(deftest "nullable type accepts nil"
|
||||
(let ((env (test-env)))
|
||||
(defcomp ~nullable-widget (&key (title :as string) (subtitle :as string?)) (div title subtitle))
|
||||
;; Passing nil for nullable param should be fine
|
||||
(let ((diagnostics
|
||||
(check-component-call "~nullable-widget" ~nullable-widget
|
||||
(rest (first (sx-parse "(~nullable-widget :title \"hi\" :subtitle nil)")))
|
||||
(dict) (test-prim-types))))
|
||||
(assert-equal 0 (len diagnostics))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Primitive call checking (Phase 5)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "check-primitive-calls"
|
||||
(deftest "correct types produce no errors"
|
||||
(let ((ppt (test-prim-param-types)))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "+" (rest (first (sx-parse "(+ 1 2 3)")))
|
||||
(dict) (test-prim-types) ppt nil)))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
|
||||
(deftest "string arg to numeric primitive produces error"
|
||||
(let ((ppt (test-prim-param-types)))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "+" (rest (first (sx-parse "(+ 1 \"hello\")")))
|
||||
(dict) (test-prim-types) ppt nil)))
|
||||
(assert-true (> (len diagnostics) 0))
|
||||
(assert-equal "error" (get (first diagnostics) "level")))))
|
||||
|
||||
(deftest "number arg to string primitive produces error"
|
||||
(let ((ppt (test-prim-param-types)))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "upper" (rest (first (sx-parse "(upper 42)")))
|
||||
(dict) (test-prim-types) ppt nil)))
|
||||
(assert-true (> (len diagnostics) 0))
|
||||
(assert-equal "error" (get (first diagnostics) "level")))))
|
||||
|
||||
(deftest "positional and rest params both checked"
|
||||
;; (- "bad" 1) — first positional arg is string, expects number
|
||||
(let ((ppt (test-prim-param-types)))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "-" (rest (first (sx-parse "(- \"bad\" 1)")))
|
||||
(dict) (test-prim-types) ppt nil)))
|
||||
(assert-true (> (len diagnostics) 0)))))
|
||||
|
||||
(deftest "dict arg to keys is valid"
|
||||
(let ((ppt (test-prim-param-types)))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "keys" (rest (first (sx-parse "(keys {:a 1})")))
|
||||
(dict) (test-prim-types) ppt nil)))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
|
||||
(deftest "number arg to keys produces error"
|
||||
(let ((ppt (test-prim-param-types)))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "keys" (rest (first (sx-parse "(keys 42)")))
|
||||
(dict) (test-prim-types) ppt nil)))
|
||||
(assert-true (> (len diagnostics) 0)))))
|
||||
|
||||
(deftest "variable with known type passes check"
|
||||
;; Variable n is known to be number in type-env
|
||||
(let ((ppt (test-prim-param-types))
|
||||
(tenv {"n" "number"}))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "inc" (rest (first (sx-parse "(inc n)")))
|
||||
tenv (test-prim-types) ppt nil)))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
|
||||
(deftest "variable with wrong type fails check"
|
||||
;; Variable s is known to be string in type-env
|
||||
(let ((ppt (test-prim-param-types))
|
||||
(tenv {"s" "string"}))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "inc" (rest (first (sx-parse "(inc s)")))
|
||||
tenv (test-prim-types) ppt nil)))
|
||||
(assert-true (> (len diagnostics) 0)))))
|
||||
|
||||
(deftest "any-typed variable skips check"
|
||||
;; Variable x has type any — should not produce errors
|
||||
(let ((ppt (test-prim-param-types))
|
||||
(tenv {"x" "any"}))
|
||||
(let ((diagnostics
|
||||
(check-primitive-call "upper" (rest (first (sx-parse "(upper x)")))
|
||||
tenv (test-prim-types) ppt nil)))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
|
||||
(deftest "body-walk catches primitive errors in component"
|
||||
;; Manually build a component and check it via check-body-walk directly
|
||||
(let ((ppt (test-prim-param-types))
|
||||
(body (first (sx-parse "(div (+ name 1))")))
|
||||
(type-env {"name" "string"})
|
||||
(diagnostics (list)))
|
||||
(check-body-walk body "~bad-math" type-env (test-prim-types) ppt (test-env) diagnostics nil nil)
|
||||
(assert-true (> (len diagnostics) 0))
|
||||
(assert-equal "error" (get (first diagnostics) "level")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; deftype — type aliases
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "deftype-alias"
|
||||
(deftest "simple alias resolves"
|
||||
(let ((registry {"price" {:name "price" :params () :body "number"}}))
|
||||
(assert-equal "number" (resolve-type "price" registry))))
|
||||
|
||||
(deftest "alias chain resolves"
|
||||
(let ((registry {"price" {:name "price" :params () :body "number"}
|
||||
"cost" {:name "cost" :params () :body "price"}}))
|
||||
(assert-equal "number" (resolve-type "cost" registry))))
|
||||
|
||||
(deftest "unknown type passes through"
|
||||
(let ((registry {"price" {:name "price" :params () :body "number"}}))
|
||||
(assert-equal "string" (resolve-type "string" registry))))
|
||||
|
||||
(deftest "subtype-resolved? works through alias"
|
||||
(let ((registry {"price" {:name "price" :params () :body "number"}}))
|
||||
(assert-true (subtype-resolved? "price" "number" registry))
|
||||
(assert-true (subtype-resolved? "number" "price" registry)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; deftype — union types
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "deftype-union"
|
||||
(deftest "union resolves"
|
||||
(let ((registry {"status" {:name "status" :params () :body ("or" "string" "number")}}))
|
||||
(let ((resolved (resolve-type "status" registry)))
|
||||
(assert-true (= (type-of resolved) "list"))
|
||||
(assert-equal "or" (first resolved)))))
|
||||
|
||||
(deftest "subtype through named union"
|
||||
(let ((registry {"status" {:name "status" :params () :body ("or" "string" "number")}}))
|
||||
(assert-true (subtype-resolved? "string" "status" registry))
|
||||
(assert-true (subtype-resolved? "number" "status" registry))
|
||||
(assert-false (subtype-resolved? "boolean" "status" registry)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; deftype — record types
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "deftype-record"
|
||||
(deftest "record resolves to dict"
|
||||
(let ((registry {"card-props" {:name "card-props" :params ()
|
||||
:body {"title" "string" "price" "number"}}}))
|
||||
(let ((resolved (resolve-type "card-props" registry)))
|
||||
(assert-equal "dict" (type-of resolved))
|
||||
(assert-equal "string" (get resolved "title"))
|
||||
(assert-equal "number" (get resolved "price")))))
|
||||
|
||||
(deftest "record structural subtyping"
|
||||
(let ((registry {"card-props" {:name "card-props" :params ()
|
||||
:body {"title" "string" "price" "number"}}
|
||||
"titled" {:name "titled" :params ()
|
||||
:body {"title" "string"}}}))
|
||||
;; card-props has title+price, titled has just title
|
||||
;; card-props <: titled (has all required fields)
|
||||
(assert-true (subtype-resolved? "card-props" "titled" registry))))
|
||||
|
||||
(deftest "get infers field type from record"
|
||||
(let ((registry {"card-props" {:name "card-props" :params ()
|
||||
:body {"title" "string" "price" "number"}}})
|
||||
(type-env {"d" "card-props"})
|
||||
(expr (first (sx-parse "(get d :title)"))))
|
||||
(assert-equal "string"
|
||||
(infer-type expr type-env (test-prim-types) registry)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; deftype — parameterized types
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "deftype-parameterized"
|
||||
(deftest "maybe instantiation"
|
||||
(let ((registry {"maybe" {:name "maybe" :params ("a")
|
||||
:body ("or" "a" "nil")}}))
|
||||
(let ((resolved (resolve-type (list "maybe" "string") registry)))
|
||||
(assert-true (= (type-of resolved) "list"))
|
||||
(assert-equal "or" (first resolved))
|
||||
(assert-true (contains? resolved "string"))
|
||||
(assert-true (contains? resolved "nil")))))
|
||||
|
||||
(deftest "subtype through parameterized type"
|
||||
(let ((registry {"maybe" {:name "maybe" :params ("a")
|
||||
:body ("or" "a" "nil")}}))
|
||||
(assert-true (subtype-resolved? "string" (list "maybe" "string") registry))
|
||||
(assert-true (subtype-resolved? "nil" (list "maybe" "string") registry))
|
||||
(assert-false (subtype-resolved? "number" (list "maybe" "string") registry))))
|
||||
|
||||
(deftest "substitute-type-vars works"
|
||||
(let ((result (substitute-type-vars ("or" "a" "nil") (list "a") (list "number"))))
|
||||
(assert-equal "or" (first result))
|
||||
(assert-true (contains? result "number"))
|
||||
(assert-true (contains? result "nil")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defeffect — effect basics
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "defeffect-basics"
|
||||
(deftest "get-effects returns nil for unannotated"
|
||||
(let ((anns {"fetch" ("io")}))
|
||||
(assert-true (nil? (get-effects "unknown" anns)))))
|
||||
|
||||
(deftest "get-effects returns effects for annotated"
|
||||
(let ((anns {"fetch" ("io")}))
|
||||
(assert-equal (list "io") (get-effects "fetch" anns))))
|
||||
|
||||
(deftest "nil annotations returns nil"
|
||||
(assert-true (nil? (get-effects "anything" nil)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defeffect — effect checking
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "effect-checking"
|
||||
(deftest "pure cannot call io"
|
||||
(let ((anns {"~pure-comp" () "fetch" ("io")}))
|
||||
(let ((diagnostics (check-effect-call "fetch" (list) anns "~pure-comp")))
|
||||
(assert-true (> (len diagnostics) 0))
|
||||
(assert-equal "error" (get (first diagnostics) "level")))))
|
||||
|
||||
(deftest "io context allows io"
|
||||
(let ((anns {"~io-comp" ("io") "fetch" ("io")}))
|
||||
(let ((diagnostics (check-effect-call "fetch" (list "io") anns "~io-comp")))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
|
||||
(deftest "unannotated caller allows everything"
|
||||
(let ((anns {"fetch" ("io")}))
|
||||
(let ((diagnostics (check-effect-call "fetch" nil anns "~unknown")))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
|
||||
(deftest "unannotated callee skips check"
|
||||
(let ((anns {"~pure-comp" ()}))
|
||||
(let ((diagnostics (check-effect-call "unknown-fn" (list) anns "~pure-comp")))
|
||||
(assert-equal 0 (len diagnostics))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defeffect — subset checking
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "effect-subset"
|
||||
(deftest "empty is subset of anything"
|
||||
(assert-true (effects-subset? (list) (list "io")))
|
||||
(assert-true (effects-subset? (list) (list))))
|
||||
|
||||
(deftest "io is subset of io"
|
||||
(assert-true (effects-subset? (list "io") (list "io" "async"))))
|
||||
|
||||
(deftest "io is not subset of pure"
|
||||
(assert-false (effects-subset? (list "io") (list))))
|
||||
|
||||
(deftest "nil callee skips check"
|
||||
(assert-true (effects-subset? nil (list))))
|
||||
|
||||
(deftest "nil caller allows all"
|
||||
(assert-true (effects-subset? (list "io") nil))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; build-effect-annotations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "build-effect-annotations"
|
||||
(deftest "builds annotations from io declarations"
|
||||
(let ((decls (list {"name" "fetch"} {"name" "save!"}))
|
||||
(anns (build-effect-annotations decls)))
|
||||
(assert-equal (list "io") (get anns "fetch"))
|
||||
(assert-equal (list "io") (get anns "save!"))))
|
||||
|
||||
(deftest "skips entries without name"
|
||||
(let ((decls (list {"name" "fetch"} {"other" "x"}))
|
||||
(anns (build-effect-annotations decls)))
|
||||
(assert-true (has-key? anns "fetch"))
|
||||
(assert-false (has-key? anns "other"))))
|
||||
|
||||
(deftest "empty declarations produce empty dict"
|
||||
(let ((anns (build-effect-annotations (list))))
|
||||
(assert-equal 0 (len (keys anns))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; check-component-effects
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Define test components at top level so they're in the main env
|
||||
(defcomp ~eff-pure-card () :effects []
|
||||
(div (fetch "url")))
|
||||
|
||||
(defcomp ~eff-io-card () :effects [io]
|
||||
(div (fetch "url")))
|
||||
|
||||
(defcomp ~eff-unannot-card ()
|
||||
(div (fetch "url")))
|
||||
|
||||
(defsuite "check-component-effects"
|
||||
(deftest "pure component calling io produces diagnostic"
|
||||
(let ((anns {"~eff-pure-card" () "fetch" ("io")})
|
||||
(diagnostics (check-component-effects "~eff-pure-card" (test-env) anns)))
|
||||
(assert-true (> (len diagnostics) 0))))
|
||||
|
||||
(deftest "io component calling io produces no diagnostic"
|
||||
(let ((anns {"~eff-io-card" ("io") "fetch" ("io")})
|
||||
(diagnostics (check-component-effects "~eff-io-card" (test-env) anns)))
|
||||
(assert-equal 0 (len diagnostics))))
|
||||
|
||||
(deftest "unannotated component skips check"
|
||||
(let ((anns {"fetch" ("io")})
|
||||
(diagnostics (check-component-effects "~eff-unannot-card" (test-env) anns)))
|
||||
(assert-equal 0 (len diagnostics)))))
|
||||
602
spec/test.sx
Normal file
602
spec/test.sx
Normal file
@@ -0,0 +1,602 @@
|
||||
;; ==========================================================================
|
||||
;; test.sx — Self-hosting SX test suite (backward-compatible entry point)
|
||||
;;
|
||||
;; This file includes the test framework and core eval tests inline.
|
||||
;; It exists for backward compatibility — runners that load "test.sx"
|
||||
;; get the same 81 tests as before.
|
||||
;;
|
||||
;; For modular testing, runners should instead load:
|
||||
;; 1. test-framework.sx (macros + assertions)
|
||||
;; 2. One or more test specs: test-eval.sx, test-parser.sx,
|
||||
;; test-router.sx, test-render.sx, etc.
|
||||
;;
|
||||
;; Platform functions required:
|
||||
;; try-call (thunk) -> {:ok true} | {:ok false :error "msg"}
|
||||
;; report-pass (name) -> platform-specific pass output
|
||||
;; report-fail (name error) -> platform-specific fail output
|
||||
;; push-suite (name) -> push suite name onto context stack
|
||||
;; pop-suite () -> pop suite name from context stack
|
||||
;;
|
||||
;; Usage:
|
||||
;; ;; Host injects platform functions into env, then:
|
||||
;; (eval-file "test.sx" env)
|
||||
;;
|
||||
;; The same test.sx runs on every host — Python, JavaScript, etc.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Test framework macros
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; deftest and defsuite are macros that make test.sx directly executable.
|
||||
;; The host provides try-call (error catching), reporting, and suite
|
||||
;; context — everything else is pure SX.
|
||||
|
||||
(defmacro deftest (name &rest body)
|
||||
`(let ((result (try-call (fn () ,@body))))
|
||||
(if (get result "ok")
|
||||
(report-pass ,name)
|
||||
(report-fail ,name (get result "error")))))
|
||||
|
||||
(defmacro defsuite (name &rest items)
|
||||
`(do (push-suite ,name)
|
||||
,@items
|
||||
(pop-suite)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Assertion helpers — defined in SX, available in test bodies
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; These are regular functions (not special forms). They use the `assert`
|
||||
;; primitive underneath but provide better error messages.
|
||||
|
||||
(define assert-equal
|
||||
(fn (expected actual)
|
||||
(assert (equal? expected actual)
|
||||
(str "Expected " (str expected) " but got " (str actual)))))
|
||||
|
||||
(define assert-not-equal
|
||||
(fn (a b)
|
||||
(assert (not (equal? a b))
|
||||
(str "Expected values to differ but both are " (str a)))))
|
||||
|
||||
(define assert-true
|
||||
(fn (val)
|
||||
(assert val (str "Expected truthy but got " (str val)))))
|
||||
|
||||
(define assert-false
|
||||
(fn (val)
|
||||
(assert (not val) (str "Expected falsy but got " (str val)))))
|
||||
|
||||
(define assert-nil
|
||||
(fn (val)
|
||||
(assert (nil? val) (str "Expected nil but got " (str val)))))
|
||||
|
||||
(define assert-type
|
||||
(fn (expected-type val)
|
||||
;; Implemented via predicate dispatch since type-of is a platform
|
||||
;; function not available in all hosts. Uses nested if to avoid
|
||||
;; Scheme-style cond detection for 2-element predicate calls.
|
||||
;; Boolean checked before number (subtypes on some platforms).
|
||||
(let ((actual-type
|
||||
(if (nil? val) "nil"
|
||||
(if (boolean? val) "boolean"
|
||||
(if (number? val) "number"
|
||||
(if (string? val) "string"
|
||||
(if (list? val) "list"
|
||||
(if (dict? val) "dict"
|
||||
"unknown"))))))))
|
||||
(assert (= expected-type actual-type)
|
||||
(str "Expected type " expected-type " but got " actual-type)))))
|
||||
|
||||
(define assert-length
|
||||
(fn (expected-len col)
|
||||
(assert (= (len col) expected-len)
|
||||
(str "Expected length " expected-len " but got " (len col)))))
|
||||
|
||||
(define assert-contains
|
||||
(fn (item col)
|
||||
(assert (some (fn (x) (equal? x item)) col)
|
||||
(str "Expected collection to contain " (str item)))))
|
||||
|
||||
(define assert-throws
|
||||
(fn (thunk)
|
||||
(let ((result (try-call thunk)))
|
||||
(assert (not (get result "ok"))
|
||||
"Expected an error to be thrown but none was"))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 3. Test suites — SX testing SX
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3a. Literals and types
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "literals"
|
||||
(deftest "numbers are numbers"
|
||||
(assert-type "number" 42)
|
||||
(assert-type "number" 3.14)
|
||||
(assert-type "number" -1))
|
||||
|
||||
(deftest "strings are strings"
|
||||
(assert-type "string" "hello")
|
||||
(assert-type "string" ""))
|
||||
|
||||
(deftest "booleans are booleans"
|
||||
(assert-type "boolean" true)
|
||||
(assert-type "boolean" false))
|
||||
|
||||
(deftest "nil is nil"
|
||||
(assert-type "nil" nil)
|
||||
(assert-nil nil))
|
||||
|
||||
(deftest "lists are lists"
|
||||
(assert-type "list" (list 1 2 3))
|
||||
(assert-type "list" (list)))
|
||||
|
||||
(deftest "dicts are dicts"
|
||||
(assert-type "dict" {:a 1 :b 2})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3b. Arithmetic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "arithmetic"
|
||||
(deftest "addition"
|
||||
(assert-equal 3 (+ 1 2))
|
||||
(assert-equal 0 (+ 0 0))
|
||||
(assert-equal -1 (+ 1 -2))
|
||||
(assert-equal 10 (+ 1 2 3 4)))
|
||||
|
||||
(deftest "subtraction"
|
||||
(assert-equal 1 (- 3 2))
|
||||
(assert-equal -1 (- 2 3)))
|
||||
|
||||
(deftest "multiplication"
|
||||
(assert-equal 6 (* 2 3))
|
||||
(assert-equal 0 (* 0 100))
|
||||
(assert-equal 24 (* 1 2 3 4)))
|
||||
|
||||
(deftest "division"
|
||||
(assert-equal 2 (/ 6 3))
|
||||
(assert-equal 2.5 (/ 5 2)))
|
||||
|
||||
(deftest "modulo"
|
||||
(assert-equal 1 (mod 7 3))
|
||||
(assert-equal 0 (mod 6 3))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3c. Comparison
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "comparison"
|
||||
(deftest "equality"
|
||||
(assert-true (= 1 1))
|
||||
(assert-false (= 1 2))
|
||||
(assert-true (= "a" "a"))
|
||||
(assert-false (= "a" "b")))
|
||||
|
||||
(deftest "deep equality"
|
||||
(assert-true (equal? (list 1 2 3) (list 1 2 3)))
|
||||
(assert-false (equal? (list 1 2) (list 1 3)))
|
||||
(assert-true (equal? {:a 1} {:a 1}))
|
||||
(assert-false (equal? {:a 1} {:a 2})))
|
||||
|
||||
(deftest "ordering"
|
||||
(assert-true (< 1 2))
|
||||
(assert-false (< 2 1))
|
||||
(assert-true (> 2 1))
|
||||
(assert-true (<= 1 1))
|
||||
(assert-true (<= 1 2))
|
||||
(assert-true (>= 2 2))
|
||||
(assert-true (>= 3 2))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3d. String operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "strings"
|
||||
(deftest "str concatenation"
|
||||
(assert-equal "abc" (str "a" "b" "c"))
|
||||
(assert-equal "hello world" (str "hello" " " "world"))
|
||||
(assert-equal "42" (str 42))
|
||||
(assert-equal "" (str)))
|
||||
|
||||
(deftest "string-length"
|
||||
(assert-equal 5 (string-length "hello"))
|
||||
(assert-equal 0 (string-length "")))
|
||||
|
||||
(deftest "substring"
|
||||
(assert-equal "ell" (substring "hello" 1 4))
|
||||
(assert-equal "hello" (substring "hello" 0 5)))
|
||||
|
||||
(deftest "string-contains?"
|
||||
(assert-true (string-contains? "hello world" "world"))
|
||||
(assert-false (string-contains? "hello" "xyz")))
|
||||
|
||||
(deftest "upcase and downcase"
|
||||
(assert-equal "HELLO" (upcase "hello"))
|
||||
(assert-equal "hello" (downcase "HELLO")))
|
||||
|
||||
(deftest "trim"
|
||||
(assert-equal "hello" (trim " hello "))
|
||||
(assert-equal "hello" (trim "hello")))
|
||||
|
||||
(deftest "split and join"
|
||||
(assert-equal (list "a" "b" "c") (split "a,b,c" ","))
|
||||
(assert-equal "a-b-c" (join "-" (list "a" "b" "c")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3e. List operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "lists"
|
||||
(deftest "constructors"
|
||||
(assert-equal (list 1 2 3) (list 1 2 3))
|
||||
(assert-equal (list) (list))
|
||||
(assert-length 3 (list 1 2 3)))
|
||||
|
||||
(deftest "first and rest"
|
||||
(assert-equal 1 (first (list 1 2 3)))
|
||||
(assert-equal (list 2 3) (rest (list 1 2 3)))
|
||||
(assert-nil (first (list)))
|
||||
(assert-equal (list) (rest (list))))
|
||||
|
||||
(deftest "nth"
|
||||
(assert-equal 1 (nth (list 1 2 3) 0))
|
||||
(assert-equal 2 (nth (list 1 2 3) 1))
|
||||
(assert-equal 3 (nth (list 1 2 3) 2)))
|
||||
|
||||
(deftest "last"
|
||||
(assert-equal 3 (last (list 1 2 3)))
|
||||
(assert-nil (last (list))))
|
||||
|
||||
(deftest "cons and append"
|
||||
(assert-equal (list 0 1 2) (cons 0 (list 1 2)))
|
||||
(assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))
|
||||
|
||||
(deftest "reverse"
|
||||
(assert-equal (list 3 2 1) (reverse (list 1 2 3)))
|
||||
(assert-equal (list) (reverse (list))))
|
||||
|
||||
(deftest "empty?"
|
||||
(assert-true (empty? (list)))
|
||||
(assert-false (empty? (list 1))))
|
||||
|
||||
(deftest "len"
|
||||
(assert-equal 0 (len (list)))
|
||||
(assert-equal 3 (len (list 1 2 3))))
|
||||
|
||||
(deftest "contains?"
|
||||
(assert-true (contains? (list 1 2 3) 2))
|
||||
(assert-false (contains? (list 1 2 3) 4)))
|
||||
|
||||
(deftest "flatten"
|
||||
(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3f. Dict operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "dicts"
|
||||
(deftest "dict literal"
|
||||
(assert-type "dict" {:a 1 :b 2})
|
||||
(assert-equal 1 (get {:a 1} "a"))
|
||||
(assert-equal 2 (get {:a 1 :b 2} "b")))
|
||||
|
||||
(deftest "assoc"
|
||||
(assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2))
|
||||
(assert-equal {:a 99} (assoc {:a 1} "a" 99)))
|
||||
|
||||
(deftest "dissoc"
|
||||
(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a")))
|
||||
|
||||
(deftest "keys and vals"
|
||||
(let ((d {:a 1 :b 2}))
|
||||
(assert-length 2 (keys d))
|
||||
(assert-length 2 (vals d))
|
||||
(assert-contains "a" (keys d))
|
||||
(assert-contains "b" (keys d))))
|
||||
|
||||
(deftest "has-key?"
|
||||
(assert-true (has-key? {:a 1} "a"))
|
||||
(assert-false (has-key? {:a 1} "b")))
|
||||
|
||||
(deftest "merge"
|
||||
(assert-equal {:a 1 :b 2 :c 3}
|
||||
(merge {:a 1 :b 2} {:c 3}))
|
||||
(assert-equal {:a 99 :b 2}
|
||||
(merge {:a 1 :b 2} {:a 99}))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3g. Predicates
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "predicates"
|
||||
(deftest "nil?"
|
||||
(assert-true (nil? nil))
|
||||
(assert-false (nil? 0))
|
||||
(assert-false (nil? false))
|
||||
(assert-false (nil? "")))
|
||||
|
||||
(deftest "number?"
|
||||
(assert-true (number? 42))
|
||||
(assert-true (number? 3.14))
|
||||
(assert-false (number? "42")))
|
||||
|
||||
(deftest "string?"
|
||||
(assert-true (string? "hello"))
|
||||
(assert-false (string? 42)))
|
||||
|
||||
(deftest "list?"
|
||||
(assert-true (list? (list 1 2)))
|
||||
(assert-false (list? "not a list")))
|
||||
|
||||
(deftest "dict?"
|
||||
(assert-true (dict? {:a 1}))
|
||||
(assert-false (dict? (list 1))))
|
||||
|
||||
(deftest "boolean?"
|
||||
(assert-true (boolean? true))
|
||||
(assert-true (boolean? false))
|
||||
(assert-false (boolean? nil))
|
||||
(assert-false (boolean? 0)))
|
||||
|
||||
(deftest "not"
|
||||
(assert-true (not false))
|
||||
(assert-true (not nil))
|
||||
(assert-false (not true))
|
||||
(assert-false (not 1))
|
||||
(assert-false (not "x"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3h. Special forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "special-forms"
|
||||
(deftest "if"
|
||||
(assert-equal "yes" (if true "yes" "no"))
|
||||
(assert-equal "no" (if false "yes" "no"))
|
||||
(assert-equal "no" (if nil "yes" "no"))
|
||||
(assert-nil (if false "yes")))
|
||||
|
||||
(deftest "when"
|
||||
(assert-equal "yes" (when true "yes"))
|
||||
(assert-nil (when false "yes")))
|
||||
|
||||
(deftest "cond"
|
||||
(assert-equal "a" (cond true "a" :else "b"))
|
||||
(assert-equal "b" (cond false "a" :else "b"))
|
||||
(assert-equal "c" (cond
|
||||
false "a"
|
||||
false "b"
|
||||
:else "c")))
|
||||
|
||||
(deftest "and"
|
||||
(assert-true (and true true))
|
||||
(assert-false (and true false))
|
||||
(assert-false (and false true))
|
||||
(assert-equal 3 (and 1 2 3)))
|
||||
|
||||
(deftest "or"
|
||||
(assert-equal 1 (or 1 2))
|
||||
(assert-equal 2 (or false 2))
|
||||
(assert-equal "fallback" (or nil false "fallback"))
|
||||
(assert-false (or false false)))
|
||||
|
||||
(deftest "let"
|
||||
(assert-equal 3 (let ((x 1) (y 2)) (+ x y)))
|
||||
(assert-equal "hello world"
|
||||
(let ((a "hello") (b " world")) (str a b))))
|
||||
|
||||
(deftest "let clojure-style"
|
||||
(assert-equal 3 (let (x 1 y 2) (+ x y))))
|
||||
|
||||
(deftest "do / begin"
|
||||
(assert-equal 3 (do 1 2 3))
|
||||
(assert-equal "last" (begin "first" "middle" "last")))
|
||||
|
||||
(deftest "define"
|
||||
(define x 42)
|
||||
(assert-equal 42 x))
|
||||
|
||||
(deftest "set!"
|
||||
(define x 1)
|
||||
(set! x 2)
|
||||
(assert-equal 2 x)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3i. Lambda and closures
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "lambdas"
|
||||
(deftest "basic lambda"
|
||||
(let ((add (fn (a b) (+ a b))))
|
||||
(assert-equal 3 (add 1 2))))
|
||||
|
||||
(deftest "closure captures env"
|
||||
(let ((x 10))
|
||||
(let ((add-x (fn (y) (+ x y))))
|
||||
(assert-equal 15 (add-x 5)))))
|
||||
|
||||
(deftest "lambda as argument"
|
||||
(assert-equal (list 2 4 6)
|
||||
(map (fn (x) (* x 2)) (list 1 2 3))))
|
||||
|
||||
(deftest "recursive lambda via define"
|
||||
(define factorial
|
||||
(fn (n) (if (<= n 1) 1 (* n (factorial (- n 1))))))
|
||||
(assert-equal 120 (factorial 5)))
|
||||
|
||||
(deftest "higher-order returns lambda"
|
||||
(let ((make-adder (fn (n) (fn (x) (+ n x)))))
|
||||
(let ((add5 (make-adder 5)))
|
||||
(assert-equal 8 (add5 3))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3j. Higher-order forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "higher-order"
|
||||
(deftest "map"
|
||||
(assert-equal (list 2 4 6)
|
||||
(map (fn (x) (* x 2)) (list 1 2 3)))
|
||||
(assert-equal (list) (map (fn (x) x) (list))))
|
||||
|
||||
(deftest "filter"
|
||||
(assert-equal (list 2 4)
|
||||
(filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4)))
|
||||
(assert-equal (list)
|
||||
(filter (fn (x) false) (list 1 2 3))))
|
||||
|
||||
(deftest "reduce"
|
||||
(assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4)))
|
||||
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))
|
||||
|
||||
(deftest "some"
|
||||
(assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5)))
|
||||
(assert-false (some (fn (x) (> x 10)) (list 1 2 3))))
|
||||
|
||||
(deftest "every?"
|
||||
(assert-true (every? (fn (x) (> x 0)) (list 1 2 3)))
|
||||
(assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))
|
||||
|
||||
(deftest "map-indexed"
|
||||
(assert-equal (list "0:a" "1:b" "2:c")
|
||||
(map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3k. Components
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "components"
|
||||
(deftest "defcomp creates component"
|
||||
(defcomp ~test-comp (&key title)
|
||||
(div title))
|
||||
;; Component is bound and not nil
|
||||
(assert-true (not (nil? ~test-comp))))
|
||||
|
||||
(deftest "component renders with keyword args"
|
||||
(defcomp ~greeting (&key name)
|
||||
(span (str "Hello, " name "!")))
|
||||
(assert-true (not (nil? ~greeting))))
|
||||
|
||||
(deftest "component with children"
|
||||
(defcomp ~box (&key &rest children)
|
||||
(div :class "box" children))
|
||||
(assert-true (not (nil? ~box))))
|
||||
|
||||
(deftest "component with default via or"
|
||||
(defcomp ~label (&key text)
|
||||
(span (or text "default")))
|
||||
(assert-true (not (nil? ~label)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3l. Macros
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "macros"
|
||||
(deftest "defmacro creates macro"
|
||||
(defmacro unless (cond &rest body)
|
||||
`(if (not ,cond) (do ,@body)))
|
||||
(assert-equal "yes" (unless false "yes"))
|
||||
(assert-nil (unless true "no")))
|
||||
|
||||
(deftest "quasiquote and unquote"
|
||||
(let ((x 42))
|
||||
(assert-equal (list 1 42 3) `(1 ,x 3))))
|
||||
|
||||
(deftest "splice-unquote"
|
||||
(let ((xs (list 2 3 4)))
|
||||
(assert-equal (list 1 2 3 4 5) `(1 ,@xs 5)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3m. Threading macro
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "threading"
|
||||
(deftest "thread-first"
|
||||
(assert-equal 8 (-> 5 (+ 1) (+ 2)))
|
||||
(assert-equal "HELLO" (-> "hello" upcase))
|
||||
(assert-equal "HELLO WORLD"
|
||||
(-> "hello"
|
||||
(str " world")
|
||||
upcase))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3n. Truthiness
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "truthiness"
|
||||
(deftest "truthy values"
|
||||
(assert-true (if 1 true false))
|
||||
(assert-true (if "x" true false))
|
||||
(assert-true (if (list 1) true false))
|
||||
(assert-true (if true true false)))
|
||||
|
||||
(deftest "falsy values"
|
||||
(assert-false (if false true false))
|
||||
(assert-false (if nil true false)))
|
||||
|
||||
;; NOTE: empty list, zero, and empty string truthiness is
|
||||
;; platform-dependent. Python treats all three as falsy.
|
||||
;; JavaScript treats [] as truthy but 0 and "" as falsy.
|
||||
;; These tests are omitted — each bootstrapper should emit
|
||||
;; platform-specific truthiness tests instead.
|
||||
)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3o. Edge cases and regression tests
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "edge-cases"
|
||||
(deftest "nested let scoping"
|
||||
(let ((x 1))
|
||||
(let ((x 2))
|
||||
(assert-equal 2 x))
|
||||
;; outer x should be unchanged by inner let
|
||||
;; (this tests that let creates a new scope)
|
||||
))
|
||||
|
||||
(deftest "recursive map"
|
||||
(assert-equal (list (list 2 4) (list 6 8))
|
||||
(map (fn (sub) (map (fn (x) (* x 2)) sub))
|
||||
(list (list 1 2) (list 3 4)))))
|
||||
|
||||
(deftest "keyword as value"
|
||||
(assert-equal "class" :class)
|
||||
(assert-equal "id" :id))
|
||||
|
||||
(deftest "dict with evaluated values"
|
||||
(let ((x 42))
|
||||
(assert-equal 42 (get {:val x} "val"))))
|
||||
|
||||
(deftest "nil propagation"
|
||||
(assert-nil (get {:a 1} "missing"))
|
||||
(assert-equal "default" (or (get {:a 1} "missing") "default")))
|
||||
|
||||
(deftest "empty operations"
|
||||
(assert-equal (list) (map (fn (x) x) (list)))
|
||||
(assert-equal (list) (filter (fn (x) true) (list)))
|
||||
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list)))
|
||||
(assert-equal 0 (len (list)))
|
||||
(assert-equal "" (str))))
|
||||
917
spec/types.sx
Normal file
917
spec/types.sx
Normal file
@@ -0,0 +1,917 @@
|
||||
;; ==========================================================================
|
||||
;; types.sx — Gradual type system for SX
|
||||
;;
|
||||
;; 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)
|
||||
;; primitives.sx, boundary.sx (return type declarations)
|
||||
;;
|
||||
;; Platform interface (from eval.sx, already provided):
|
||||
;; (type-of x) → type string
|
||||
;; (symbol-name s) → string
|
||||
;; (keyword-name k) → string
|
||||
;; (component-body c) → AST
|
||||
;; (component-params c) → list of param name strings
|
||||
;; (component-has-children c) → boolean
|
||||
;; (env-get env k) → value or nil
|
||||
;;
|
||||
;; New platform functions for types.sx:
|
||||
;; (component-param-types c) → dict {param-name → type} or nil
|
||||
;; (component-set-param-types! c d) → store param types on component
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Type representation
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Types are plain SX values:
|
||||
;; - Strings for base types: "number", "string", "boolean", "nil",
|
||||
;; "symbol", "keyword", "element", "any", "never"
|
||||
;; - Nullable shorthand: "string?", "number?", "dict?", "boolean?"
|
||||
;; → equivalent to (or string nil) etc.
|
||||
;; - Lists for compound types:
|
||||
;; (or t1 t2 ...) — union
|
||||
;; (list-of t) — homogeneous list
|
||||
;; (dict-of tk tv) — typed dict
|
||||
;; (-> t1 t2 ... treturn) — function type (last is return)
|
||||
|
||||
;; Base type names
|
||||
(define base-types
|
||||
(list "number" "string" "boolean" "nil" "symbol" "keyword"
|
||||
"element" "any" "never" "list" "dict"
|
||||
"lambda" "component" "island" "macro" "signal"))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Type predicates
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define type-any?
|
||||
(fn (t) (= t "any")))
|
||||
|
||||
(define type-never?
|
||||
(fn (t) (= t "never")))
|
||||
|
||||
(define type-nullable?
|
||||
(fn (t)
|
||||
;; A type is nullable if it's "any", "nil", a "?" shorthand, or
|
||||
;; a union containing "nil".
|
||||
(if (= t "any") true
|
||||
(if (= t "nil") true
|
||||
(if (and (= (type-of t) "string") (ends-with? t "?")) true
|
||||
(if (and (= (type-of t) "list")
|
||||
(not (empty? t))
|
||||
(= (first t) "or"))
|
||||
(contains? (rest t) "nil")
|
||||
false))))))
|
||||
|
||||
(define nullable-base
|
||||
(fn (t)
|
||||
;; Strip "?" from nullable shorthand: "string?" → "string"
|
||||
(if (and (= (type-of t) "string")
|
||||
(ends-with? t "?")
|
||||
(not (= t "?")))
|
||||
(slice t 0 (- (string-length t) 1))
|
||||
t)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Subtype checking
|
||||
;; --------------------------------------------------------------------------
|
||||
;; subtype?(a, b) — is type `a` assignable to type `b`?
|
||||
|
||||
(define subtype?
|
||||
(fn (a b)
|
||||
;; any accepts everything
|
||||
(if (type-any? b) true
|
||||
;; never is subtype of everything
|
||||
(if (type-never? a) true
|
||||
;; any is not a subtype of a specific type
|
||||
(if (type-any? a) false
|
||||
;; identical types
|
||||
(if (= a b) true
|
||||
;; nil is subtype of nullable types
|
||||
(if (= a "nil")
|
||||
(type-nullable? b)
|
||||
;; nullable shorthand: "string?" = (or string nil)
|
||||
(if (and (= (type-of b) "string") (ends-with? b "?"))
|
||||
(let ((base (nullable-base b)))
|
||||
(or (= a base) (= a "nil")))
|
||||
;; a is a union: (or t1 t2 ...) <: b if ALL members <: b
|
||||
;; Must check before b-union — (or A B) <: (or A B C) needs
|
||||
;; each member of a checked against the full union b.
|
||||
(if (and (= (type-of a) "list")
|
||||
(not (empty? a))
|
||||
(= (first a) "or"))
|
||||
(every? (fn (member) (subtype? member b)) (rest a))
|
||||
;; union: a <: (or t1 t2 ...) if a <: any member
|
||||
(if (and (= (type-of b) "list")
|
||||
(not (empty? b))
|
||||
(= (first b) "or"))
|
||||
(some (fn (member) (subtype? a member)) (rest b))
|
||||
;; list-of covariance
|
||||
(if (and (= (type-of a) "list") (= (type-of b) "list")
|
||||
(= (len a) 2) (= (len b) 2)
|
||||
(= (first a) "list-of") (= (first b) "list-of"))
|
||||
(subtype? (nth a 1) (nth b 1))
|
||||
;; "list" <: (list-of any)
|
||||
(if (and (= a "list")
|
||||
(= (type-of b) "list")
|
||||
(= (len b) 2)
|
||||
(= (first b) "list-of"))
|
||||
(type-any? (nth b 1))
|
||||
;; (list-of t) <: "list"
|
||||
(if (and (= (type-of a) "list")
|
||||
(= (len a) 2)
|
||||
(= (first a) "list-of")
|
||||
(= b "list"))
|
||||
true
|
||||
;; "element" is subtype of "string?" (rendered HTML)
|
||||
false)))))))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Type union
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define type-union
|
||||
(fn (a b)
|
||||
;; Compute the smallest type that encompasses both a and b.
|
||||
(if (= a b) a
|
||||
(if (type-any? a) "any"
|
||||
(if (type-any? b) "any"
|
||||
(if (type-never? a) b
|
||||
(if (type-never? b) a
|
||||
(if (subtype? a b) b
|
||||
(if (subtype? b a) a
|
||||
;; neither is subtype — create a union
|
||||
(if (= a "nil")
|
||||
;; nil + string → string?
|
||||
(if (and (= (type-of b) "string")
|
||||
(not (ends-with? b "?")))
|
||||
(str b "?")
|
||||
(list "or" a b))
|
||||
(if (= b "nil")
|
||||
(if (and (= (type-of a) "string")
|
||||
(not (ends-with? a "?")))
|
||||
(str a "?")
|
||||
(list "or" a b))
|
||||
(list "or" a b))))))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Type narrowing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define narrow-type
|
||||
(fn (t (predicate-name :as string))
|
||||
;; Narrow type based on a predicate test in a truthy branch.
|
||||
;; (if (nil? x) ..then.. ..else..) → in else, x excludes nil.
|
||||
;; Returns (narrowed-then narrowed-else).
|
||||
(if (= predicate-name "nil?")
|
||||
(list "nil" (narrow-exclude-nil t))
|
||||
(if (= predicate-name "string?")
|
||||
(list "string" (narrow-exclude t "string"))
|
||||
(if (= predicate-name "number?")
|
||||
(list "number" (narrow-exclude t "number"))
|
||||
(if (= predicate-name "list?")
|
||||
(list "list" (narrow-exclude t "list"))
|
||||
(if (= predicate-name "dict?")
|
||||
(list "dict" (narrow-exclude t "dict"))
|
||||
(if (= predicate-name "boolean?")
|
||||
(list "boolean" (narrow-exclude t "boolean"))
|
||||
;; Unknown predicate — no narrowing
|
||||
(list t t)))))))))
|
||||
|
||||
|
||||
(define narrow-exclude-nil
|
||||
(fn (t)
|
||||
;; Remove nil from a type.
|
||||
(if (= t "nil") "never"
|
||||
(if (= t "any") "any" ;; can't narrow any
|
||||
(if (and (= (type-of t) "string") (ends-with? t "?"))
|
||||
(nullable-base t)
|
||||
(if (and (= (type-of t) "list")
|
||||
(not (empty? t))
|
||||
(= (first t) "or"))
|
||||
(let ((members (filter (fn (m) (not (= m "nil"))) (rest t))))
|
||||
(if (= (len members) 1) (first members)
|
||||
(if (empty? members) "never"
|
||||
(cons "or" members))))
|
||||
t))))))
|
||||
|
||||
|
||||
(define narrow-exclude
|
||||
(fn (t excluded)
|
||||
;; Remove a specific type from a union.
|
||||
(if (= t excluded) "never"
|
||||
(if (= t "any") "any"
|
||||
(if (and (= (type-of t) "list")
|
||||
(not (empty? t))
|
||||
(= (first t) "or"))
|
||||
(let ((members (filter (fn (m) (not (= m excluded))) (rest t))))
|
||||
(if (= (len members) 1) (first members)
|
||||
(if (empty? members) "never"
|
||||
(cons "or" members))))
|
||||
t)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Type inference
|
||||
;; --------------------------------------------------------------------------
|
||||
;; infer-type walks an AST node and returns its inferred type.
|
||||
;; type-env is a dict mapping variable names → types.
|
||||
|
||||
(define infer-type
|
||||
(fn (node (type-env :as dict) (prim-types :as dict) type-registry)
|
||||
(let ((kind (type-of node)))
|
||||
(if (= kind "number") "number"
|
||||
(if (= kind "string") "string"
|
||||
(if (= kind "boolean") "boolean"
|
||||
(if (nil? node) "nil"
|
||||
(if (= kind "keyword") "keyword"
|
||||
(if (= kind "symbol")
|
||||
(let ((name (symbol-name node)))
|
||||
;; Look up in type env
|
||||
(if (has-key? type-env name)
|
||||
(get type-env name)
|
||||
;; Builtins
|
||||
(if (= name "true") "boolean"
|
||||
(if (= name "false") "boolean"
|
||||
(if (= name "nil") "nil"
|
||||
;; Check primitive return types
|
||||
(if (has-key? prim-types name)
|
||||
(get prim-types name)
|
||||
"any"))))))
|
||||
(if (= kind "dict") "dict"
|
||||
(if (= kind "list")
|
||||
(infer-list-type node type-env prim-types type-registry)
|
||||
"any")))))))))))
|
||||
|
||||
|
||||
(define infer-list-type
|
||||
(fn (node (type-env :as dict) (prim-types :as dict) type-registry)
|
||||
;; Infer type of a list expression (function call, special form, etc.)
|
||||
(if (empty? node) "list"
|
||||
(let ((head (first node))
|
||||
(args (rest node)))
|
||||
(if (not (= (type-of head) "symbol"))
|
||||
"any" ;; complex head — can't infer
|
||||
(let ((name (symbol-name head)))
|
||||
;; Special forms
|
||||
(if (= name "if")
|
||||
(infer-if-type args type-env prim-types type-registry)
|
||||
(if (= name "when")
|
||||
(if (>= (len args) 2)
|
||||
(type-union (infer-type (last args) type-env prim-types type-registry) "nil")
|
||||
"nil")
|
||||
(if (or (= name "cond") (= name "case"))
|
||||
"any" ;; complex — could be refined later
|
||||
(if (= name "let")
|
||||
(infer-let-type args type-env prim-types type-registry)
|
||||
(if (or (= name "do") (= name "begin"))
|
||||
(if (empty? args) "nil"
|
||||
(infer-type (last args) type-env prim-types type-registry))
|
||||
(if (or (= name "lambda") (= name "fn"))
|
||||
"lambda"
|
||||
(if (= name "and")
|
||||
(if (empty? args) "boolean"
|
||||
(infer-type (last args) type-env prim-types type-registry))
|
||||
(if (= name "or")
|
||||
(if (empty? args) "boolean"
|
||||
;; or returns first truthy — union of all args
|
||||
(reduce type-union "never"
|
||||
(map (fn (a) (infer-type a type-env prim-types type-registry)) args)))
|
||||
(if (= name "map")
|
||||
;; map returns a list
|
||||
(if (>= (len args) 2)
|
||||
(let ((fn-type (infer-type (first args) type-env prim-types type-registry)))
|
||||
;; If the fn's return type is known, produce (list-of return-type)
|
||||
(if (and (= (type-of fn-type) "list")
|
||||
(= (first fn-type) "->"))
|
||||
(list "list-of" (last fn-type))
|
||||
"list"))
|
||||
"list")
|
||||
(if (= name "filter")
|
||||
;; filter preserves element type
|
||||
(if (>= (len args) 2)
|
||||
(infer-type (nth args 1) type-env prim-types type-registry)
|
||||
"list")
|
||||
(if (= name "reduce")
|
||||
;; reduce returns the accumulator type — too complex to infer
|
||||
"any"
|
||||
(if (= name "list")
|
||||
"list"
|
||||
(if (= name "dict")
|
||||
"dict"
|
||||
(if (= name "quote")
|
||||
"any"
|
||||
(if (= name "str")
|
||||
"string"
|
||||
(if (= name "not")
|
||||
"boolean"
|
||||
(if (= name "get")
|
||||
;; get — resolve record field type from type registry
|
||||
(if (and (>= (len args) 2) (not (nil? type-registry)))
|
||||
(let ((dict-type (infer-type (first args) type-env prim-types type-registry))
|
||||
(key-arg (nth args 1))
|
||||
(key-name (cond
|
||||
(= (type-of key-arg) "keyword") (keyword-name key-arg)
|
||||
(= (type-of key-arg) "string") key-arg
|
||||
:else nil)))
|
||||
(if (and key-name
|
||||
(= (type-of dict-type) "string")
|
||||
(has-key? type-registry dict-type))
|
||||
(let ((resolved (resolve-type dict-type type-registry)))
|
||||
(if (and (= (type-of resolved) "dict")
|
||||
(has-key? resolved key-name))
|
||||
(get resolved key-name)
|
||||
"any"))
|
||||
"any"))
|
||||
"any")
|
||||
(if (starts-with? name "~")
|
||||
"element" ;; component call
|
||||
;; Regular function call: look up return type
|
||||
(if (has-key? prim-types name)
|
||||
(get prim-types name)
|
||||
"any")))))))))))))))))))))))))
|
||||
|
||||
|
||||
(define infer-if-type
|
||||
(fn ((args :as list) (type-env :as dict) (prim-types :as dict) type-registry)
|
||||
;; (if test then else?) → union of then and else types
|
||||
(if (< (len args) 2) "nil"
|
||||
(let ((then-type (infer-type (nth args 1) type-env prim-types type-registry)))
|
||||
(if (>= (len args) 3)
|
||||
(type-union then-type (infer-type (nth args 2) type-env prim-types type-registry))
|
||||
(type-union then-type "nil"))))))
|
||||
|
||||
|
||||
(define infer-let-type
|
||||
(fn ((args :as list) (type-env :as dict) (prim-types :as dict) type-registry)
|
||||
;; (let ((x expr) ...) body) → type of body in extended type-env
|
||||
(if (< (len args) 2) "nil"
|
||||
(let ((bindings (first args))
|
||||
(body (last args))
|
||||
(extended (merge type-env (dict))))
|
||||
;; Add binding types
|
||||
(for-each
|
||||
(fn (binding)
|
||||
(when (and (= (type-of binding) "list") (>= (len binding) 2))
|
||||
(let ((name (if (= (type-of (first binding)) "symbol")
|
||||
(symbol-name (first binding))
|
||||
(str (first binding))))
|
||||
(val-type (infer-type (nth binding 1) extended prim-types type-registry)))
|
||||
(dict-set! extended name val-type))))
|
||||
bindings)
|
||||
(infer-type body extended prim-types type-registry)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. Diagnostic types
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Diagnostics are dicts:
|
||||
;; {:level "error"|"warning"|"info"
|
||||
;; :message "human-readable explanation"
|
||||
;; :component "~name" (or nil for top-level)
|
||||
;; :expr <the offending AST node>}
|
||||
|
||||
(define make-diagnostic
|
||||
(fn ((level :as string) (message :as string) component expr)
|
||||
{:level level
|
||||
:message message
|
||||
:component component
|
||||
:expr expr}))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. Call-site checking
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define check-primitive-call
|
||||
(fn ((name :as string) (args :as list) (type-env :as dict) (prim-types :as dict) prim-param-types (comp-name :as string) type-registry)
|
||||
;; Check a primitive call site against declared param types.
|
||||
;; prim-param-types is a dict: {prim-name → {:positional [...] :rest-type type-or-nil}}
|
||||
;; Each positional entry is a list (name type-or-nil).
|
||||
;; Returns list of diagnostics.
|
||||
(let ((diagnostics (list)))
|
||||
(when (and (not (nil? prim-param-types))
|
||||
(has-key? prim-param-types name))
|
||||
(let ((sig (get prim-param-types name))
|
||||
(positional (get sig "positional"))
|
||||
(rest-type (get sig "rest-type")))
|
||||
;; Check each positional arg
|
||||
(for-each
|
||||
(fn (idx)
|
||||
(when (< idx (len args))
|
||||
(if (< idx (len positional))
|
||||
;; Positional param — check against declared type
|
||||
(let ((param-info (nth positional idx))
|
||||
(arg-expr (nth args idx)))
|
||||
(let ((expected-type (nth param-info 1)))
|
||||
(when (not (nil? expected-type))
|
||||
(let ((actual (infer-type arg-expr type-env prim-types type-registry)))
|
||||
(when (and (not (type-any? expected-type))
|
||||
(not (type-any? actual))
|
||||
(not (subtype-resolved? actual expected-type type-registry)))
|
||||
(append! diagnostics
|
||||
(make-diagnostic "error"
|
||||
(str "Argument " (+ idx 1) " of `" name
|
||||
"` expects " expected-type ", got " actual)
|
||||
comp-name arg-expr)))))))
|
||||
;; Rest param — check against rest-type
|
||||
(when (not (nil? rest-type))
|
||||
(let ((arg-expr (nth args idx))
|
||||
(actual (infer-type arg-expr type-env prim-types type-registry)))
|
||||
(when (and (not (type-any? rest-type))
|
||||
(not (type-any? actual))
|
||||
(not (subtype-resolved? actual rest-type type-registry)))
|
||||
(append! diagnostics
|
||||
(make-diagnostic "error"
|
||||
(str "Argument " (+ idx 1) " of `" name
|
||||
"` expects " rest-type ", got " actual)
|
||||
comp-name arg-expr))))))))
|
||||
(range 0 (len args) 1))))
|
||||
diagnostics)))
|
||||
|
||||
|
||||
(define check-component-call
|
||||
(fn ((comp-name :as string) (comp :as component) (call-args :as list) (type-env :as dict) (prim-types :as dict) type-registry)
|
||||
;; Check a component call site against its declared param types.
|
||||
;; comp is the component value, call-args is the list of args
|
||||
;; from the call site (after the component name).
|
||||
(let ((diagnostics (list))
|
||||
(param-types (component-param-types comp))
|
||||
(params (component-params comp)))
|
||||
(when (and (not (nil? param-types))
|
||||
(not (empty? (keys param-types))))
|
||||
;; Parse keyword args from call site
|
||||
(let ((i 0)
|
||||
(provided-keys (list)))
|
||||
(for-each
|
||||
(fn (idx)
|
||||
(when (< idx (len call-args))
|
||||
(let ((arg (nth call-args idx)))
|
||||
(when (= (type-of arg) "keyword")
|
||||
(let ((key-name (keyword-name arg)))
|
||||
(append! provided-keys key-name)
|
||||
(when (< (+ idx 1) (len call-args))
|
||||
(let ((val-expr (nth call-args (+ idx 1))))
|
||||
;; Check type of value against declared param type
|
||||
(when (has-key? param-types key-name)
|
||||
(let ((expected (get param-types key-name))
|
||||
(actual (infer-type val-expr type-env prim-types type-registry)))
|
||||
(when (and (not (type-any? expected))
|
||||
(not (type-any? actual))
|
||||
(not (subtype-resolved? actual expected type-registry)))
|
||||
(append! diagnostics
|
||||
(make-diagnostic "error"
|
||||
(str "Keyword :" key-name " of " comp-name
|
||||
" expects " expected ", got " actual)
|
||||
comp-name val-expr))))))))))))
|
||||
(range 0 (len call-args) 1))
|
||||
|
||||
;; Check for missing required params (those with declared types)
|
||||
(for-each
|
||||
(fn (param-name)
|
||||
(when (and (has-key? param-types param-name)
|
||||
(not (contains? provided-keys param-name))
|
||||
(not (type-nullable? (get param-types param-name))))
|
||||
(append! diagnostics
|
||||
(make-diagnostic "warning"
|
||||
(str "Required param :" param-name " of " comp-name " not provided")
|
||||
comp-name nil))))
|
||||
params)
|
||||
|
||||
;; Check for unknown kwargs
|
||||
(for-each
|
||||
(fn (key)
|
||||
(when (not (contains? params key))
|
||||
(append! diagnostics
|
||||
(make-diagnostic "warning"
|
||||
(str "Unknown keyword :" key " passed to " comp-name)
|
||||
comp-name nil))))
|
||||
provided-keys)))
|
||||
diagnostics)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. AST walker — check a component body
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define check-body-walk
|
||||
(fn (node (comp-name :as string) (type-env :as dict) (prim-types :as dict) prim-param-types env (diagnostics :as list) type-registry effect-annotations)
|
||||
;; Recursively walk an AST and collect diagnostics.
|
||||
;; prim-param-types: dict of {name → {:positional [...] :rest-type t}} or nil
|
||||
;; type-registry: dict of {type-name → type-def} or nil
|
||||
;; effect-annotations: dict of {fn-name → effect-list} or nil
|
||||
(let ((kind (type-of node)))
|
||||
(when (= kind "list")
|
||||
(when (not (empty? node))
|
||||
(let ((head (first node))
|
||||
(args (rest node)))
|
||||
;; Check calls when head is a symbol
|
||||
(when (= (type-of head) "symbol")
|
||||
(let ((name (symbol-name head)))
|
||||
;; Component call
|
||||
(when (starts-with? name "~")
|
||||
(let ((comp-val (env-get env name)))
|
||||
(when (= (type-of comp-val) "component")
|
||||
(for-each
|
||||
(fn (d) (append! diagnostics d))
|
||||
(check-component-call name comp-val args
|
||||
type-env prim-types type-registry))))
|
||||
;; Effect check for component calls
|
||||
(when (not (nil? effect-annotations))
|
||||
(let ((caller-effects (get-effects comp-name effect-annotations)))
|
||||
(for-each
|
||||
(fn (d) (append! diagnostics d))
|
||||
(check-effect-call name caller-effects effect-annotations comp-name)))))
|
||||
|
||||
;; Primitive call — check param types
|
||||
(when (and (not (starts-with? name "~"))
|
||||
(not (nil? prim-param-types))
|
||||
(has-key? prim-param-types name))
|
||||
(for-each
|
||||
(fn (d) (append! diagnostics d))
|
||||
(check-primitive-call name args type-env prim-types
|
||||
prim-param-types comp-name type-registry)))
|
||||
|
||||
;; Effect check for function calls
|
||||
(when (and (not (starts-with? name "~"))
|
||||
(not (nil? effect-annotations)))
|
||||
(let ((caller-effects (get-effects comp-name effect-annotations)))
|
||||
(for-each
|
||||
(fn (d) (append! diagnostics d))
|
||||
(check-effect-call name caller-effects effect-annotations comp-name))))
|
||||
|
||||
;; Recurse into let with extended type env
|
||||
(when (or (= name "let") (= name "let*"))
|
||||
(when (>= (len args) 2)
|
||||
(let ((bindings (first args))
|
||||
(body-exprs (rest args))
|
||||
(extended (merge type-env (dict))))
|
||||
(for-each
|
||||
(fn (binding)
|
||||
(when (and (= (type-of binding) "list")
|
||||
(>= (len binding) 2))
|
||||
(let ((bname (if (= (type-of (first binding)) "symbol")
|
||||
(symbol-name (first binding))
|
||||
(str (first binding))))
|
||||
(val-type (infer-type (nth binding 1) extended prim-types type-registry)))
|
||||
(dict-set! extended bname val-type))))
|
||||
bindings)
|
||||
(for-each
|
||||
(fn (body)
|
||||
(check-body-walk body comp-name extended prim-types prim-param-types env diagnostics type-registry effect-annotations))
|
||||
body-exprs))))
|
||||
|
||||
;; Recurse into define with type binding
|
||||
(when (= name "define")
|
||||
(when (>= (len args) 2)
|
||||
(let ((def-name (if (= (type-of (first args)) "symbol")
|
||||
(symbol-name (first args))
|
||||
nil))
|
||||
(def-val (nth args 1)))
|
||||
(when def-name
|
||||
(dict-set! type-env def-name
|
||||
(infer-type def-val type-env prim-types type-registry)))
|
||||
(check-body-walk def-val comp-name type-env prim-types prim-param-types env diagnostics type-registry effect-annotations))))))
|
||||
|
||||
;; Recurse into all child expressions
|
||||
(for-each
|
||||
(fn (child)
|
||||
(check-body-walk child comp-name type-env prim-types prim-param-types env diagnostics type-registry effect-annotations))
|
||||
args)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 10. Check a single component
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define check-component
|
||||
(fn ((comp-name :as string) env (prim-types :as dict) prim-param-types type-registry effect-annotations)
|
||||
;; Type-check a component's body. Returns list of diagnostics.
|
||||
;; prim-param-types: dict of param type info, or nil to skip primitive checking.
|
||||
;; type-registry: dict of {type-name → type-def} or nil
|
||||
;; effect-annotations: dict of {fn-name → effect-list} or nil
|
||||
(let ((comp (env-get env comp-name))
|
||||
(diagnostics (list)))
|
||||
(when (= (type-of comp) "component")
|
||||
(let ((body (component-body comp))
|
||||
(params (component-params comp))
|
||||
(param-types (component-param-types comp))
|
||||
;; Build initial type env from component params
|
||||
(type-env (dict)))
|
||||
;; Add param types (annotated or default to any)
|
||||
(for-each
|
||||
(fn (p)
|
||||
(dict-set! type-env p
|
||||
(if (and (not (nil? param-types))
|
||||
(has-key? param-types p))
|
||||
(get param-types p)
|
||||
"any")))
|
||||
params)
|
||||
;; Add children as (list-of element) if component has children
|
||||
(when (component-has-children comp)
|
||||
(dict-set! type-env "children" (list "list-of" "element")))
|
||||
|
||||
(check-body-walk body comp-name type-env prim-types prim-param-types env diagnostics type-registry effect-annotations)))
|
||||
diagnostics)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 11. Check all components in an environment
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define check-all
|
||||
(fn (env (prim-types :as dict) prim-param-types type-registry effect-annotations)
|
||||
;; Type-check every component in the environment.
|
||||
;; prim-param-types: dict of param type info, or nil to skip primitive checking.
|
||||
;; type-registry: dict of {type-name → type-def} or nil
|
||||
;; effect-annotations: dict of {fn-name → effect-list} or nil
|
||||
;; Returns list of all diagnostics.
|
||||
(let ((all-diagnostics (list)))
|
||||
(for-each
|
||||
(fn (name)
|
||||
(let ((val (env-get env name)))
|
||||
(when (= (type-of val) "component")
|
||||
(for-each
|
||||
(fn (d) (append! all-diagnostics d))
|
||||
(check-component name env prim-types prim-param-types type-registry effect-annotations)))))
|
||||
(keys env))
|
||||
all-diagnostics)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 12. Build primitive type registry
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Builds a dict mapping primitive-name → return-type from
|
||||
;; the declarations parsed by boundary_parser.py.
|
||||
;; This is called by the host at startup with the parsed declarations.
|
||||
|
||||
(define build-type-registry
|
||||
(fn ((prim-declarations :as list) (io-declarations :as list))
|
||||
;; Both are lists of dicts: {:name "+" :returns "number" :params (...)}
|
||||
;; Returns a flat dict: {"+" "number", "str" "string", ...}
|
||||
(let ((registry (dict)))
|
||||
(for-each
|
||||
(fn (decl)
|
||||
(let ((name (get decl "name"))
|
||||
(returns (get decl "returns")))
|
||||
(when (and (not (nil? name)) (not (nil? returns)))
|
||||
(dict-set! registry name returns))))
|
||||
prim-declarations)
|
||||
(for-each
|
||||
(fn (decl)
|
||||
(let ((name (get decl "name"))
|
||||
(returns (get decl "returns")))
|
||||
(when (and (not (nil? name)) (not (nil? returns)))
|
||||
(dict-set! registry name returns))))
|
||||
io-declarations)
|
||||
registry)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 13. User-defined types (deftype)
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Type definitions are plain dicts: {:name "price" :params [] :body "number"}
|
||||
;; Stored in env under "*type-registry*" mapping type names to defs.
|
||||
|
||||
;; make-type-def and normalize-type-body are defined in eval.sx
|
||||
;; (always compiled). They're available when types.sx is compiled as a spec module.
|
||||
|
||||
;; -- Standard type definitions --
|
||||
;; These define the record types used throughout the type system itself.
|
||||
|
||||
;; Universal: nullable shorthand
|
||||
(deftype (maybe a) (union a nil))
|
||||
|
||||
;; A type definition entry in the registry
|
||||
(deftype type-def
|
||||
{:name string :params list :body any})
|
||||
|
||||
;; A diagnostic produced by the type checker
|
||||
(deftype diagnostic
|
||||
{:level string :message string :component string? :expr any})
|
||||
|
||||
;; Primitive parameter type signature
|
||||
(deftype prim-param-sig
|
||||
{:positional list :rest-type string?})
|
||||
|
||||
;; Effect declarations
|
||||
(defeffect io)
|
||||
(defeffect mutation)
|
||||
(defeffect render)
|
||||
|
||||
(define type-def-name
|
||||
(fn (td) (get td "name")))
|
||||
|
||||
(define type-def-params
|
||||
(fn (td) (get td "params")))
|
||||
|
||||
(define type-def-body
|
||||
(fn (td) (get td "body")))
|
||||
|
||||
(define resolve-type
|
||||
(fn (t registry)
|
||||
;; Resolve a type through the registry.
|
||||
;; Returns the resolved type representation.
|
||||
(if (nil? registry) t
|
||||
(cond
|
||||
;; String — might be a named type alias
|
||||
(= (type-of t) "string")
|
||||
(if (has-key? registry t)
|
||||
(let ((td (get registry t)))
|
||||
(let ((params (type-def-params td))
|
||||
(body (type-def-body td)))
|
||||
(if (empty? params)
|
||||
;; Simple alias — resolve the body recursively
|
||||
(resolve-type body registry)
|
||||
;; Parameterized with no args — return as-is
|
||||
t)))
|
||||
t)
|
||||
;; List — might be parameterized type application or compound
|
||||
(= (type-of t) "list")
|
||||
(if (empty? t) t
|
||||
(let ((head (first t)))
|
||||
(cond
|
||||
;; (or ...), (list-of ...), (-> ...) — recurse into members
|
||||
(or (= head "or") (= head "list-of") (= head "->")
|
||||
(= head "dict-of"))
|
||||
(cons head (map (fn (m) (resolve-type m registry)) (rest t)))
|
||||
;; Parameterized type application: ("maybe" "string") etc.
|
||||
(and (= (type-of head) "string")
|
||||
(has-key? registry head))
|
||||
(let ((td (get registry head))
|
||||
(params (type-def-params td))
|
||||
(body (type-def-body td))
|
||||
(args (rest t)))
|
||||
(if (= (len params) (len args))
|
||||
(resolve-type
|
||||
(substitute-type-vars body params args)
|
||||
registry)
|
||||
;; Wrong arity — return as-is
|
||||
t))
|
||||
:else t)))
|
||||
;; Dict — record type, resolve field types
|
||||
(= (type-of t) "dict")
|
||||
(map-dict (fn (k v) (resolve-type v registry)) t)
|
||||
;; Anything else — return as-is
|
||||
:else t))))
|
||||
|
||||
(define substitute-type-vars
|
||||
(fn (body (params :as list) (args :as list))
|
||||
;; Substitute type variables in body.
|
||||
;; params is a list of type var names, args is corresponding types.
|
||||
(let ((subst (dict)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(dict-set! subst (nth params i) (nth args i)))
|
||||
(range 0 (len params) 1))
|
||||
(substitute-in-type body subst))))
|
||||
|
||||
(define substitute-in-type
|
||||
(fn (t (subst :as dict))
|
||||
;; Recursively substitute type variables.
|
||||
(cond
|
||||
(= (type-of t) "string")
|
||||
(if (has-key? subst t) (get subst t) t)
|
||||
(= (type-of t) "list")
|
||||
(map (fn (m) (substitute-in-type m subst)) t)
|
||||
(= (type-of t) "dict")
|
||||
(map-dict (fn (k v) (substitute-in-type v subst)) t)
|
||||
:else t)))
|
||||
|
||||
(define subtype-resolved?
|
||||
(fn (a b registry)
|
||||
;; Resolve both sides through the registry, then check subtype.
|
||||
(if (nil? registry)
|
||||
(subtype? a b)
|
||||
(let ((ra (resolve-type a registry))
|
||||
(rb (resolve-type b registry)))
|
||||
;; Handle record structural subtyping: dict a <: dict b
|
||||
;; if every field in b exists in a with compatible type
|
||||
(if (and (= (type-of ra) "dict") (= (type-of rb) "dict"))
|
||||
(every?
|
||||
(fn (key)
|
||||
(and (has-key? ra key)
|
||||
(subtype-resolved? (get ra key) (get rb key) registry)))
|
||||
(keys rb))
|
||||
(subtype? ra rb))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 14. Effect checking (defeffect)
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Effects are annotations on functions/components describing their
|
||||
;; side effects. A pure function cannot call IO functions.
|
||||
|
||||
(define get-effects
|
||||
(fn ((name :as string) effect-annotations)
|
||||
;; Look up declared effects for a function/component.
|
||||
;; Returns list of effect strings, or nil if unannotated.
|
||||
(if (nil? effect-annotations) nil
|
||||
(if (has-key? effect-annotations name)
|
||||
(get effect-annotations name)
|
||||
nil))))
|
||||
|
||||
(define effects-subset?
|
||||
(fn (callee-effects caller-effects)
|
||||
;; Are all callee effects allowed by caller?
|
||||
;; nil effects = unannotated = assumed to have all effects.
|
||||
;; Empty list = pure = no effects.
|
||||
(if (nil? caller-effects) true ;; unannotated caller allows everything
|
||||
(if (nil? callee-effects) true ;; unannotated callee — skip check
|
||||
(every?
|
||||
(fn (e) (contains? caller-effects e))
|
||||
callee-effects)))))
|
||||
|
||||
(define check-effect-call
|
||||
(fn ((callee-name :as string) caller-effects effect-annotations (comp-name :as string))
|
||||
;; Check that callee's effects are allowed by caller's effects.
|
||||
;; Returns list of diagnostics.
|
||||
(let ((diagnostics (list))
|
||||
(callee-effects (get-effects callee-name effect-annotations)))
|
||||
(when (and (not (nil? caller-effects))
|
||||
(not (nil? callee-effects))
|
||||
(not (effects-subset? callee-effects caller-effects)))
|
||||
(append! diagnostics
|
||||
(make-diagnostic "error"
|
||||
(str "`" callee-name "` has effects "
|
||||
(join ", " callee-effects)
|
||||
" but `" comp-name "` only allows "
|
||||
(if (empty? caller-effects) "[pure]"
|
||||
(join ", " caller-effects)))
|
||||
comp-name nil)))
|
||||
diagnostics)))
|
||||
|
||||
(define build-effect-annotations
|
||||
(fn ((io-declarations :as list))
|
||||
;; Assign [io] effect to all IO primitives.
|
||||
(let ((annotations (dict)))
|
||||
(for-each
|
||||
(fn (decl)
|
||||
(let ((name (get decl "name")))
|
||||
(when (not (nil? name))
|
||||
(dict-set! annotations name (list "io")))))
|
||||
io-declarations)
|
||||
annotations)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 15. Check component effects — convenience wrapper
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Validates that components respect their declared effect annotations.
|
||||
;; Delegates to check-body-walk with nil type checking (effects only).
|
||||
|
||||
(define check-component-effects
|
||||
(fn ((comp-name :as string) env effect-annotations)
|
||||
;; Check a single component's effect usage. Returns diagnostics list.
|
||||
;; Skips type checking — only checks effect violations.
|
||||
(let ((comp (env-get env comp-name))
|
||||
(diagnostics (list)))
|
||||
(when (= (type-of comp) "component")
|
||||
(let ((body (component-body comp)))
|
||||
(check-body-walk body comp-name (dict) (dict) nil env
|
||||
diagnostics nil effect-annotations)))
|
||||
diagnostics)))
|
||||
|
||||
(define check-all-effects
|
||||
(fn (env effect-annotations)
|
||||
;; Check all components in env for effect violations.
|
||||
;; Returns list of all diagnostics.
|
||||
(let ((all-diagnostics (list)))
|
||||
(for-each
|
||||
(fn (name)
|
||||
(let ((val (env-get env name)))
|
||||
(when (= (type-of val) "component")
|
||||
(for-each
|
||||
(fn (d) (append! all-diagnostics d))
|
||||
(check-component-effects name env effect-annotations)))))
|
||||
(keys env))
|
||||
all-diagnostics)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface summary
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; From eval.sx (already provided):
|
||||
;; (type-of x), (symbol-name s), (keyword-name k), (env-get env k)
|
||||
;; (component-body c), (component-params c), (component-has-children c)
|
||||
;;
|
||||
;; New for types.sx (each host implements):
|
||||
;; (component-param-types c) → dict {param-name → type} or nil
|
||||
;; (component-set-param-types! c d) → store param types on component
|
||||
;; (merge d1 d2) → new dict merging d1 and d2
|
||||
;;
|
||||
;; Primitive param types:
|
||||
;; The host provides prim-param-types as a dict mapping primitive names
|
||||
;; to param type descriptors. Each descriptor is a dict:
|
||||
;; {"positional" [["name" "type-or-nil"] ...] "rest-type" "type-or-nil"}
|
||||
;; Built by boundary_parser.parse_primitive_param_types() in Python.
|
||||
;; Passed to check-component/check-all as an optional extra argument.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
1375
web/adapter-async.sx
Normal file
1375
web/adapter-async.sx
Normal file
File diff suppressed because it is too large
Load Diff
1314
web/adapter-dom.sx
Normal file
1314
web/adapter-dom.sx
Normal file
File diff suppressed because it is too large
Load Diff
545
web/adapter-html.sx
Normal file
545
web/adapter-html.sx
Normal file
@@ -0,0 +1,545 @@
|
||||
;; ==========================================================================
|
||||
;; adapter-html.sx — HTML string rendering adapter
|
||||
;;
|
||||
;; Renders evaluated SX expressions to HTML strings. Used server-side.
|
||||
;;
|
||||
;; Depends on:
|
||||
;; render.sx — HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS,
|
||||
;; parse-element-args, render-attrs, definition-form?
|
||||
;; eval.sx — eval-expr, trampoline, expand-macro, process-bindings,
|
||||
;; eval-cond, env-has?, env-get, env-set!, env-merge,
|
||||
;; lambda?, component?, island?, macro?,
|
||||
;; lambda-closure, lambda-params, lambda-body
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
(define render-to-html :effects [render]
|
||||
(fn (expr (env :as dict))
|
||||
(set-render-active! true)
|
||||
(case (type-of expr)
|
||||
;; Literals — render directly
|
||||
"nil" ""
|
||||
"string" (escape-html expr)
|
||||
"number" (str expr)
|
||||
"boolean" (if expr "true" "false")
|
||||
;; List — dispatch to render-list which handles HTML tags, special forms, etc.
|
||||
"list" (if (empty? expr) "" (render-list-to-html expr env))
|
||||
;; Symbol — evaluate then render
|
||||
"symbol" (render-value-to-html (trampoline (eval-expr expr env)) env)
|
||||
;; Keyword — render as text
|
||||
"keyword" (escape-html (keyword-name expr))
|
||||
;; Raw HTML passthrough
|
||||
"raw-html" (raw-html-content expr)
|
||||
;; Spread — emit attrs to nearest element provider
|
||||
"spread" (do (emit! "element-attrs" (spread-attrs expr)) "")
|
||||
;; Everything else — evaluate first
|
||||
:else (render-value-to-html (trampoline (eval-expr expr env)) env))))
|
||||
|
||||
(define render-value-to-html :effects [render]
|
||||
(fn (val (env :as dict))
|
||||
(case (type-of val)
|
||||
"nil" ""
|
||||
"string" (escape-html val)
|
||||
"number" (str val)
|
||||
"boolean" (if val "true" "false")
|
||||
"list" (render-list-to-html val env)
|
||||
"raw-html" (raw-html-content val)
|
||||
"spread" (do (emit! "element-attrs" (spread-attrs val)) "")
|
||||
:else (escape-html (str val)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Render-aware form classification
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define RENDER_HTML_FORMS
|
||||
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
|
||||
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
|
||||
"deftype" "defeffect"
|
||||
"map" "map-indexed" "filter" "for-each" "scope" "provide"))
|
||||
|
||||
(define render-html-form? :effects []
|
||||
(fn ((name :as string))
|
||||
(contains? RENDER_HTML_FORMS name)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-list-to-html — dispatch on list head
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define render-list-to-html :effects [render]
|
||||
(fn ((expr :as list) (env :as dict))
|
||||
(if (empty? expr)
|
||||
""
|
||||
(let ((head (first expr)))
|
||||
(if (not (= (type-of head) "symbol"))
|
||||
;; Data list — render each item
|
||||
(join "" (map (fn (x) (render-value-to-html x env)) expr))
|
||||
(let ((name (symbol-name head))
|
||||
(args (rest expr)))
|
||||
(cond
|
||||
;; Fragment
|
||||
(= name "<>")
|
||||
(join "" (map (fn (x) (render-to-html x env)) args))
|
||||
|
||||
;; Raw HTML passthrough
|
||||
(= name "raw!")
|
||||
(join "" (map (fn (x) (str (trampoline (eval-expr x env)))) args))
|
||||
|
||||
;; Lake — server-morphable slot within an island
|
||||
(= name "lake")
|
||||
(render-html-lake args env)
|
||||
|
||||
;; Marsh — reactive server-morphable slot within an island
|
||||
(= name "marsh")
|
||||
(render-html-marsh args env)
|
||||
|
||||
;; HTML tag
|
||||
(contains? HTML_TAGS name)
|
||||
(render-html-element name args env)
|
||||
|
||||
;; Island (~name) — reactive component, SSR with hydration markers
|
||||
(and (starts-with? name "~")
|
||||
(env-has? env name)
|
||||
(island? (env-get env name)))
|
||||
(render-html-island (env-get env name) args env)
|
||||
|
||||
;; Component or macro call (~name)
|
||||
(starts-with? name "~")
|
||||
(let ((val (env-get env name)))
|
||||
(cond
|
||||
(component? val)
|
||||
(render-html-component val args env)
|
||||
(macro? val)
|
||||
(render-to-html
|
||||
(expand-macro val args env)
|
||||
env)
|
||||
:else
|
||||
(error (str "Unknown component: " name))))
|
||||
|
||||
;; Render-aware special forms
|
||||
(render-html-form? name)
|
||||
(dispatch-html-form name expr env)
|
||||
|
||||
;; Macro expansion
|
||||
(and (env-has? env name) (macro? (env-get env name)))
|
||||
(render-to-html
|
||||
(expand-macro (env-get env name) args env)
|
||||
env)
|
||||
|
||||
;; Fallback — evaluate then render result
|
||||
:else
|
||||
(render-value-to-html
|
||||
(trampoline (eval-expr expr env))
|
||||
env))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; dispatch-html-form — render-aware special form handling for HTML output
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define dispatch-html-form :effects [render]
|
||||
(fn ((name :as string) (expr :as list) (env :as dict))
|
||||
(cond
|
||||
;; if
|
||||
(= name "if")
|
||||
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
|
||||
(if cond-val
|
||||
(render-to-html (nth expr 2) env)
|
||||
(if (> (len expr) 3)
|
||||
(render-to-html (nth expr 3) env)
|
||||
"")))
|
||||
|
||||
;; when — single body: pass through. Multi: join strings.
|
||||
(= name "when")
|
||||
(if (not (trampoline (eval-expr (nth expr 1) env)))
|
||||
""
|
||||
(if (= (len expr) 3)
|
||||
(render-to-html (nth expr 2) env)
|
||||
(join "" (map (fn (i) (render-to-html (nth expr i) env))
|
||||
(range 2 (len expr))))))
|
||||
|
||||
;; cond
|
||||
(= name "cond")
|
||||
(let ((branch (eval-cond (rest expr) env)))
|
||||
(if branch
|
||||
(render-to-html branch env)
|
||||
""))
|
||||
|
||||
;; case
|
||||
(= name "case")
|
||||
(render-to-html (trampoline (eval-expr expr env)) env)
|
||||
|
||||
;; let / let* — single body: pass through. Multi: join strings.
|
||||
(or (= name "let") (= name "let*"))
|
||||
(let ((local (process-bindings (nth expr 1) env)))
|
||||
(if (= (len expr) 3)
|
||||
(render-to-html (nth expr 2) local)
|
||||
(join "" (map (fn (i) (render-to-html (nth expr i) local))
|
||||
(range 2 (len expr))))))
|
||||
|
||||
;; begin / do — single body: pass through. Multi: join strings.
|
||||
(or (= name "begin") (= name "do"))
|
||||
(if (= (len expr) 2)
|
||||
(render-to-html (nth expr 1) env)
|
||||
(join "" (map (fn (i) (render-to-html (nth expr i) env))
|
||||
(range 1 (len expr)))))
|
||||
|
||||
;; Definition forms — eval for side effects
|
||||
(definition-form? name)
|
||||
(do (trampoline (eval-expr expr env)) "")
|
||||
|
||||
;; map
|
||||
(= name "map")
|
||||
(let ((f (trampoline (eval-expr (nth expr 1) env)))
|
||||
(coll (trampoline (eval-expr (nth expr 2) env))))
|
||||
(join ""
|
||||
(map
|
||||
(fn (item)
|
||||
(if (lambda? f)
|
||||
(render-lambda-html f (list item) env)
|
||||
(render-to-html (apply f (list item)) env)))
|
||||
coll)))
|
||||
|
||||
;; map-indexed
|
||||
(= name "map-indexed")
|
||||
(let ((f (trampoline (eval-expr (nth expr 1) env)))
|
||||
(coll (trampoline (eval-expr (nth expr 2) env))))
|
||||
(join ""
|
||||
(map-indexed
|
||||
(fn (i item)
|
||||
(if (lambda? f)
|
||||
(render-lambda-html f (list i item) env)
|
||||
(render-to-html (apply f (list i item)) env)))
|
||||
coll)))
|
||||
|
||||
;; filter — evaluate fully then render
|
||||
(= name "filter")
|
||||
(render-to-html (trampoline (eval-expr expr env)) env)
|
||||
|
||||
;; for-each (render variant)
|
||||
(= name "for-each")
|
||||
(let ((f (trampoline (eval-expr (nth expr 1) env)))
|
||||
(coll (trampoline (eval-expr (nth expr 2) env))))
|
||||
(join ""
|
||||
(map
|
||||
(fn (item)
|
||||
(if (lambda? f)
|
||||
(render-lambda-html f (list item) env)
|
||||
(render-to-html (apply f (list item)) env)))
|
||||
coll)))
|
||||
|
||||
;; scope — unified render-time dynamic scope
|
||||
(= name "scope")
|
||||
(let ((scope-name (trampoline (eval-expr (nth expr 1) env)))
|
||||
(rest-args (slice expr 2))
|
||||
(scope-val nil)
|
||||
(body-exprs nil))
|
||||
;; Check for :value keyword
|
||||
(if (and (>= (len rest-args) 2)
|
||||
(= (type-of (first rest-args)) "keyword")
|
||||
(= (keyword-name (first rest-args)) "value"))
|
||||
(do (set! scope-val (trampoline (eval-expr (nth rest-args 1) env)))
|
||||
(set! body-exprs (slice rest-args 2)))
|
||||
(set! body-exprs rest-args))
|
||||
(scope-push! scope-name scope-val)
|
||||
(let ((result (if (= (len body-exprs) 1)
|
||||
(render-to-html (first body-exprs) env)
|
||||
(join "" (map (fn (e) (render-to-html e env)) body-exprs)))))
|
||||
(scope-pop! scope-name)
|
||||
result))
|
||||
|
||||
;; provide — sugar for scope with value
|
||||
(= name "provide")
|
||||
(let ((prov-name (trampoline (eval-expr (nth expr 1) env)))
|
||||
(prov-val (trampoline (eval-expr (nth expr 2) env)))
|
||||
(body-start 3)
|
||||
(body-count (- (len expr) 3)))
|
||||
(scope-push! prov-name prov-val)
|
||||
(let ((result (if (= body-count 1)
|
||||
(render-to-html (nth expr body-start) env)
|
||||
(join "" (map (fn (i) (render-to-html (nth expr i) env))
|
||||
(range body-start (+ body-start body-count)))))))
|
||||
(scope-pop! prov-name)
|
||||
result))
|
||||
|
||||
;; Fallback
|
||||
:else
|
||||
(render-value-to-html (trampoline (eval-expr expr env)) env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-lambda-html — render a lambda body in HTML context
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define render-lambda-html :effects [render]
|
||||
(fn ((f :as lambda) (args :as list) (env :as dict))
|
||||
(let ((local (env-merge (lambda-closure f) env)))
|
||||
(for-each-indexed
|
||||
(fn (i p)
|
||||
(env-set! local p (nth args i)))
|
||||
(lambda-params f))
|
||||
(render-to-html (lambda-body f) local))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-html-component — expand and render a component
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define render-html-component :effects [render]
|
||||
(fn ((comp :as component) (args :as list) (env :as dict))
|
||||
;; Expand component and render body through HTML adapter.
|
||||
;; Component body contains rendering forms (HTML tags) that only the
|
||||
;; adapter understands, so expansion must happen here, not in eval-expr.
|
||||
(let ((kwargs (dict))
|
||||
(children (list)))
|
||||
;; Separate keyword args from positional children
|
||||
(reduce
|
||||
(fn (state arg)
|
||||
(let ((skip (get state "skip")))
|
||||
(if skip
|
||||
(assoc state "skip" false "i" (inc (get state "i")))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc (get state "i")) (len args)))
|
||||
(let ((val (trampoline
|
||||
(eval-expr (nth args (inc (get state "i"))) env))))
|
||||
(dict-set! kwargs (keyword-name arg) val)
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
(do
|
||||
(append! children arg)
|
||||
(assoc state "i" (inc (get state "i"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
;; Build component env: closure + caller env + params
|
||||
(let ((local (env-merge (component-closure comp) env)))
|
||||
;; Bind params from kwargs
|
||||
(for-each
|
||||
(fn (p)
|
||||
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
||||
(component-params comp))
|
||||
;; If component accepts children, pre-render them to raw HTML
|
||||
(when (component-has-children? comp)
|
||||
(env-set! local "children"
|
||||
(make-raw-html (join "" (map (fn (c) (render-to-html c env)) children)))))
|
||||
(render-to-html (component-body comp) local)))))
|
||||
|
||||
|
||||
(define render-html-element :effects [render]
|
||||
(fn ((tag :as string) (args :as list) (env :as dict))
|
||||
(let ((parsed (parse-element-args args env))
|
||||
(attrs (first parsed))
|
||||
(children (nth parsed 1))
|
||||
(is-void (contains? VOID_ELEMENTS tag)))
|
||||
(if is-void
|
||||
(str "<" tag (render-attrs attrs) " />")
|
||||
;; Provide scope for spread emit!
|
||||
(do
|
||||
(scope-push! "element-attrs" nil)
|
||||
(let ((content (join "" (map (fn (c) (render-to-html c env)) children))))
|
||||
(for-each
|
||||
(fn (spread-dict) (merge-spread-attrs attrs spread-dict))
|
||||
(emitted "element-attrs"))
|
||||
(scope-pop! "element-attrs")
|
||||
(str "<" tag (render-attrs attrs) ">"
|
||||
content
|
||||
"</" tag ">")))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-html-lake — SSR rendering of a server-morphable slot
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (lake :id "name" children...) → <div data-sx-lake="name">children</div>
|
||||
;;
|
||||
;; Lakes are server territory inside islands. The morph can update lake
|
||||
;; content while preserving surrounding reactive DOM.
|
||||
|
||||
(define render-html-lake :effects [render]
|
||||
(fn ((args :as list) (env :as dict))
|
||||
(let ((lake-id nil)
|
||||
(lake-tag "div")
|
||||
(children (list)))
|
||||
(reduce
|
||||
(fn (state arg)
|
||||
(let ((skip (get state "skip")))
|
||||
(if skip
|
||||
(assoc state "skip" false "i" (inc (get state "i")))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc (get state "i")) (len args)))
|
||||
(let ((kname (keyword-name arg))
|
||||
(kval (trampoline (eval-expr (nth args (inc (get state "i"))) env))))
|
||||
(cond
|
||||
(= kname "id") (set! lake-id kval)
|
||||
(= kname "tag") (set! lake-tag kval))
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
(do
|
||||
(append! children arg)
|
||||
(assoc state "i" (inc (get state "i"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
;; Provide scope for spread emit!
|
||||
(let ((lake-attrs (dict "data-sx-lake" (or lake-id ""))))
|
||||
(scope-push! "element-attrs" nil)
|
||||
(let ((content (join "" (map (fn (c) (render-to-html c env)) children))))
|
||||
(for-each
|
||||
(fn (spread-dict) (merge-spread-attrs lake-attrs spread-dict))
|
||||
(emitted "element-attrs"))
|
||||
(scope-pop! "element-attrs")
|
||||
(str "<" lake-tag (render-attrs lake-attrs) ">"
|
||||
content
|
||||
"</" lake-tag ">"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-html-marsh — SSR rendering of a reactive server-morphable slot
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (marsh :id "name" :tag "div" :transform fn children...)
|
||||
;; → <div data-sx-marsh="name">children</div>
|
||||
;;
|
||||
;; Like a lake but reactive: during morph, new content is parsed as SX and
|
||||
;; re-evaluated in the island's signal scope. Server renders children normally;
|
||||
;; the :transform is a client-only concern.
|
||||
|
||||
(define render-html-marsh :effects [render]
|
||||
(fn ((args :as list) (env :as dict))
|
||||
(let ((marsh-id nil)
|
||||
(marsh-tag "div")
|
||||
(children (list)))
|
||||
(reduce
|
||||
(fn (state arg)
|
||||
(let ((skip (get state "skip")))
|
||||
(if skip
|
||||
(assoc state "skip" false "i" (inc (get state "i")))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc (get state "i")) (len args)))
|
||||
(let ((kname (keyword-name arg))
|
||||
(kval (trampoline (eval-expr (nth args (inc (get state "i"))) env))))
|
||||
(cond
|
||||
(= kname "id") (set! marsh-id kval)
|
||||
(= kname "tag") (set! marsh-tag kval)
|
||||
(= kname "transform") nil)
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
(do
|
||||
(append! children arg)
|
||||
(assoc state "i" (inc (get state "i"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
;; Provide scope for spread emit!
|
||||
(let ((marsh-attrs (dict "data-sx-marsh" (or marsh-id ""))))
|
||||
(scope-push! "element-attrs" nil)
|
||||
(let ((content (join "" (map (fn (c) (render-to-html c env)) children))))
|
||||
(for-each
|
||||
(fn (spread-dict) (merge-spread-attrs marsh-attrs spread-dict))
|
||||
(emitted "element-attrs"))
|
||||
(scope-pop! "element-attrs")
|
||||
(str "<" marsh-tag (render-attrs marsh-attrs) ">"
|
||||
content
|
||||
"</" marsh-tag ">"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-html-island — SSR rendering of a reactive island
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Renders the island body as static HTML wrapped in a container element
|
||||
;; with data-sx-island and data-sx-state attributes. The client hydrates
|
||||
;; this by finding these elements and re-rendering with reactive context.
|
||||
;;
|
||||
;; On the server, signal/deref/reset!/swap! are simple passthrough:
|
||||
;; (signal val) → returns val (no container needed server-side)
|
||||
;; (deref s) → returns s (signal values are plain values server-side)
|
||||
;; (reset! s v) → no-op
|
||||
;; (swap! s f) → no-op
|
||||
|
||||
(define render-html-island :effects [render]
|
||||
(fn ((island :as island) (args :as list) (env :as dict))
|
||||
;; Parse kwargs and children (same pattern as render-html-component)
|
||||
(let ((kwargs (dict))
|
||||
(children (list)))
|
||||
(reduce
|
||||
(fn (state arg)
|
||||
(let ((skip (get state "skip")))
|
||||
(if skip
|
||||
(assoc state "skip" false "i" (inc (get state "i")))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc (get state "i")) (len args)))
|
||||
(let ((val (trampoline
|
||||
(eval-expr (nth args (inc (get state "i"))) env))))
|
||||
(dict-set! kwargs (keyword-name arg) val)
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
(do
|
||||
(append! children arg)
|
||||
(assoc state "i" (inc (get state "i"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
|
||||
;; Build island env: closure + caller env + params
|
||||
(let ((local (env-merge (component-closure island) env))
|
||||
(island-name (component-name island)))
|
||||
|
||||
;; Bind params from kwargs
|
||||
(for-each
|
||||
(fn (p)
|
||||
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
||||
(component-params island))
|
||||
|
||||
;; If island accepts children, pre-render them to raw HTML
|
||||
(when (component-has-children? island)
|
||||
(env-set! local "children"
|
||||
(make-raw-html (join "" (map (fn (c) (render-to-html c env)) children)))))
|
||||
|
||||
;; Render the island body as HTML
|
||||
(let ((body-html (render-to-html (component-body island) local))
|
||||
(state-sx (serialize-island-state kwargs)))
|
||||
;; Wrap in container with hydration attributes
|
||||
(str "<span data-sx-island=\"" (escape-attr island-name) "\""
|
||||
(if state-sx
|
||||
(str " data-sx-state=\"" (escape-attr state-sx) "\"")
|
||||
"")
|
||||
">"
|
||||
body-html
|
||||
"</span>"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; serialize-island-state — serialize kwargs to SX for hydration
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Uses the SX serializer (not JSON) so the client can parse with sx-parse.
|
||||
;; Handles all SX types natively: numbers, strings, booleans, nil, lists, dicts.
|
||||
|
||||
(define serialize-island-state :effects []
|
||||
(fn ((kwargs :as dict))
|
||||
(if (empty-dict? kwargs)
|
||||
nil
|
||||
(sx-serialize kwargs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — HTML adapter
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Inherited from render.sx:
|
||||
;; escape-html, escape-attr, raw-html-content
|
||||
;;
|
||||
;; From eval.sx:
|
||||
;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond
|
||||
;; env-has?, env-get, env-set!, env-merge
|
||||
;; lambda?, component?, island?, macro?
|
||||
;; lambda-closure, lambda-params, lambda-body
|
||||
;; component-params, component-body, component-closure,
|
||||
;; component-has-children?, component-name
|
||||
;;
|
||||
;; Raw HTML construction:
|
||||
;; (make-raw-html s) → wrap string as raw HTML (not double-escaped)
|
||||
;;
|
||||
;; Island state serialization:
|
||||
;; (sx-serialize val) → SX source string (from parser.sx)
|
||||
;; (empty-dict? d) → boolean
|
||||
;; (escape-attr s) → HTML attribute escape
|
||||
;;
|
||||
;; Iteration:
|
||||
;; (for-each-indexed fn coll) → call fn(index, item) for each element
|
||||
;; (map-indexed fn coll) → map fn(index, item) over each element
|
||||
;; --------------------------------------------------------------------------
|
||||
407
web/adapter-sx.sx
Normal file
407
web/adapter-sx.sx
Normal file
@@ -0,0 +1,407 @@
|
||||
;; ==========================================================================
|
||||
;; adapter-sx.sx — SX wire format rendering adapter
|
||||
;;
|
||||
;; Serializes SX expressions for client-side rendering.
|
||||
;; Component calls are NOT expanded — they're sent to the client as-is.
|
||||
;; HTML tags are serialized as SX source text. Special forms are evaluated.
|
||||
;;
|
||||
;; Depends on:
|
||||
;; render.sx — HTML_TAGS
|
||||
;; eval.sx — eval-expr, trampoline, call-lambda, expand-macro
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
(define render-to-sx :effects [render]
|
||||
(fn (expr (env :as dict))
|
||||
(let ((result (aser expr env)))
|
||||
;; aser-call already returns serialized SX strings;
|
||||
;; only serialize non-string values
|
||||
(if (= (type-of result) "string")
|
||||
result
|
||||
(serialize result)))))
|
||||
|
||||
(define aser :effects [render]
|
||||
(fn ((expr :as any) (env :as dict))
|
||||
;; Evaluate for SX wire format — serialize rendering forms,
|
||||
;; evaluate control flow and function calls.
|
||||
(set-render-active! true)
|
||||
(let ((result
|
||||
(case (type-of expr)
|
||||
"number" expr
|
||||
"string" expr
|
||||
"boolean" expr
|
||||
"nil" nil
|
||||
|
||||
"symbol"
|
||||
(let ((name (symbol-name expr)))
|
||||
(cond
|
||||
(env-has? env name) (env-get env name)
|
||||
(primitive? name) (get-primitive name)
|
||||
(= name "true") true
|
||||
(= name "false") false
|
||||
(= name "nil") nil
|
||||
:else (error (str "Undefined symbol: " name))))
|
||||
|
||||
"keyword" (keyword-name expr)
|
||||
|
||||
"list"
|
||||
(if (empty? expr)
|
||||
(list)
|
||||
(aser-list expr env))
|
||||
|
||||
;; Spread — emit attrs to nearest element provider
|
||||
"spread" (do (emit! "element-attrs" (spread-attrs expr)) nil)
|
||||
|
||||
:else expr)))
|
||||
;; Catch spread values from function calls and symbol lookups
|
||||
(if (spread? result)
|
||||
(do (emit! "element-attrs" (spread-attrs result)) nil)
|
||||
result))))
|
||||
|
||||
|
||||
(define aser-list :effects [render]
|
||||
(fn ((expr :as list) (env :as dict))
|
||||
(let ((head (first expr))
|
||||
(args (rest expr)))
|
||||
(if (not (= (type-of head) "symbol"))
|
||||
(map (fn (x) (aser x env)) expr)
|
||||
(let ((name (symbol-name head)))
|
||||
(cond
|
||||
;; Fragment — serialize children
|
||||
(= name "<>")
|
||||
(aser-fragment args env)
|
||||
|
||||
;; Component call — serialize WITHOUT expanding
|
||||
(starts-with? name "~")
|
||||
(aser-call name args env)
|
||||
|
||||
;; Lake — serialize (server-morphable slot)
|
||||
(= name "lake")
|
||||
(aser-call name args env)
|
||||
|
||||
;; Marsh — serialize (reactive server-morphable slot)
|
||||
(= name "marsh")
|
||||
(aser-call name args env)
|
||||
|
||||
;; HTML tag — serialize
|
||||
(contains? HTML_TAGS name)
|
||||
(aser-call name args env)
|
||||
|
||||
;; Special/HO forms — evaluate (produces data)
|
||||
(or (special-form? name) (ho-form? name))
|
||||
(aser-special name expr env)
|
||||
|
||||
;; Macro — expand then aser
|
||||
(and (env-has? env name) (macro? (env-get env name)))
|
||||
(aser (expand-macro (env-get env name) args env) env)
|
||||
|
||||
;; Function call — evaluate fully
|
||||
:else
|
||||
(let ((f (trampoline (eval-expr head env)))
|
||||
(evaled-args (map (fn (a) (trampoline (eval-expr a env))) args)))
|
||||
(cond
|
||||
(and (callable? f) (not (lambda? f)) (not (component? f)) (not (island? f)))
|
||||
(apply f evaled-args)
|
||||
(lambda? f)
|
||||
(trampoline (call-lambda f evaled-args env))
|
||||
(component? f)
|
||||
(aser-call (str "~" (component-name f)) args env)
|
||||
(island? f)
|
||||
(aser-call (str "~" (component-name f)) args env)
|
||||
:else (error (str "Not callable: " (inspect f)))))))))))
|
||||
|
||||
|
||||
(define aser-fragment :effects [render]
|
||||
(fn ((children :as list) (env :as dict))
|
||||
;; Serialize (<> child1 child2 ...) to sx source string
|
||||
;; Must flatten list results (e.g. from map/filter) to avoid nested parens
|
||||
(let ((parts (list)))
|
||||
(for-each
|
||||
(fn (c)
|
||||
(let ((result (aser c env)))
|
||||
(if (= (type-of result) "list")
|
||||
(for-each
|
||||
(fn (item)
|
||||
(when (not (nil? item))
|
||||
(append! parts (serialize item))))
|
||||
result)
|
||||
(when (not (nil? result))
|
||||
(append! parts (serialize result))))))
|
||||
children)
|
||||
(if (empty? parts)
|
||||
""
|
||||
(str "(<> " (join " " parts) ")")))))
|
||||
|
||||
|
||||
(define aser-call :effects [render]
|
||||
(fn ((name :as string) (args :as list) (env :as dict))
|
||||
;; Serialize (name :key val child ...) — evaluate args but keep as sx
|
||||
;; Uses for-each + mutable state (not reduce) so bootstrapper emits for-loops
|
||||
;; that can contain nested for-each for list flattening.
|
||||
;; Separate attrs and children so emitted spread attrs go before children.
|
||||
(let ((attr-parts (list))
|
||||
(child-parts (list))
|
||||
(skip false)
|
||||
(i 0))
|
||||
;; Provide scope for spread emit!
|
||||
(scope-push! "element-attrs" nil)
|
||||
(for-each
|
||||
(fn (arg)
|
||||
(if skip
|
||||
(do (set! skip false)
|
||||
(set! i (inc i)))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc i) (len args)))
|
||||
(let ((val (aser (nth args (inc i)) env)))
|
||||
(when (not (nil? val))
|
||||
(append! attr-parts (str ":" (keyword-name arg)))
|
||||
(append! attr-parts (serialize val)))
|
||||
(set! skip true)
|
||||
(set! i (inc i)))
|
||||
(let ((val (aser arg env)))
|
||||
(when (not (nil? val))
|
||||
(if (= (type-of val) "list")
|
||||
(for-each
|
||||
(fn (item)
|
||||
(when (not (nil? item))
|
||||
(append! child-parts (serialize item))))
|
||||
val)
|
||||
(append! child-parts (serialize val))))
|
||||
(set! i (inc i))))))
|
||||
args)
|
||||
;; Collect emitted spread attrs — goes after explicit attrs, before children
|
||||
(for-each
|
||||
(fn (spread-dict)
|
||||
(for-each
|
||||
(fn (k)
|
||||
(let ((v (dict-get spread-dict k)))
|
||||
(append! attr-parts (str ":" k))
|
||||
(append! attr-parts (serialize v))))
|
||||
(keys spread-dict)))
|
||||
(emitted "element-attrs"))
|
||||
(scope-pop! "element-attrs")
|
||||
(let ((parts (concat (list name) attr-parts child-parts)))
|
||||
(str "(" (join " " parts) ")")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Form classification
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define SPECIAL_FORM_NAMES
|
||||
(list "if" "when" "cond" "case" "and" "or"
|
||||
"let" "let*" "lambda" "fn"
|
||||
"define" "defcomp" "defmacro" "defstyle"
|
||||
"defhandler" "defpage" "defquery" "defaction" "defrelation"
|
||||
"begin" "do" "quote" "quasiquote"
|
||||
"->" "set!" "letrec" "dynamic-wind" "defisland"
|
||||
"deftype" "defeffect" "scope" "provide"))
|
||||
|
||||
(define HO_FORM_NAMES
|
||||
(list "map" "map-indexed" "filter" "reduce"
|
||||
"some" "every?" "for-each"))
|
||||
|
||||
(define special-form? :effects []
|
||||
(fn ((name :as string))
|
||||
(contains? SPECIAL_FORM_NAMES name)))
|
||||
|
||||
(define ho-form? :effects []
|
||||
(fn ((name :as string))
|
||||
(contains? HO_FORM_NAMES name)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; aser-special — evaluate special/HO forms in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Control flow forms evaluate conditions normally but render branches
|
||||
;; through aser (serializing tags/components instead of rendering HTML).
|
||||
;; Definition forms evaluate for side effects and return nil.
|
||||
|
||||
(define aser-special :effects [render]
|
||||
(fn ((name :as string) (expr :as list) (env :as dict))
|
||||
(let ((args (rest expr)))
|
||||
(cond
|
||||
;; if — evaluate condition, aser chosen branch
|
||||
(= name "if")
|
||||
(if (trampoline (eval-expr (first args) env))
|
||||
(aser (nth args 1) env)
|
||||
(if (> (len args) 2)
|
||||
(aser (nth args 2) env)
|
||||
nil))
|
||||
|
||||
;; when — evaluate condition, aser body if true
|
||||
(= name "when")
|
||||
(if (not (trampoline (eval-expr (first args) env)))
|
||||
nil
|
||||
(let ((result nil))
|
||||
(for-each (fn (body) (set! result (aser body env)))
|
||||
(rest args))
|
||||
result))
|
||||
|
||||
;; cond — evaluate conditions, aser matching branch
|
||||
(= name "cond")
|
||||
(let ((branch (eval-cond args env)))
|
||||
(if branch (aser branch env) nil))
|
||||
|
||||
;; case — evaluate match value, check each pair
|
||||
(= name "case")
|
||||
(let ((match-val (trampoline (eval-expr (first args) env)))
|
||||
(clauses (rest args)))
|
||||
(eval-case-aser match-val clauses env))
|
||||
|
||||
;; let / let*
|
||||
(or (= name "let") (= name "let*"))
|
||||
(let ((local (process-bindings (first args) env))
|
||||
(result nil))
|
||||
(for-each (fn (body) (set! result (aser body local)))
|
||||
(rest args))
|
||||
result)
|
||||
|
||||
;; begin / do
|
||||
(or (= name "begin") (= name "do"))
|
||||
(let ((result nil))
|
||||
(for-each (fn (body) (set! result (aser body env))) args)
|
||||
result)
|
||||
|
||||
;; and — short-circuit
|
||||
(= name "and")
|
||||
(let ((result true))
|
||||
(some (fn (arg)
|
||||
(set! result (trampoline (eval-expr arg env)))
|
||||
(not result))
|
||||
args)
|
||||
result)
|
||||
|
||||
;; or — short-circuit
|
||||
(= name "or")
|
||||
(let ((result false))
|
||||
(some (fn (arg)
|
||||
(set! result (trampoline (eval-expr arg env)))
|
||||
result)
|
||||
args)
|
||||
result)
|
||||
|
||||
;; map — evaluate function and collection, map through aser
|
||||
(= name "map")
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(map (fn (item)
|
||||
(if (lambda? f)
|
||||
(let ((local (env-merge (lambda-closure f) env)))
|
||||
(env-set! local (first (lambda-params f)) item)
|
||||
(aser (lambda-body f) local))
|
||||
(cek-call f (list item))))
|
||||
coll))
|
||||
|
||||
;; map-indexed
|
||||
(= name "map-indexed")
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(map-indexed (fn (i item)
|
||||
(if (lambda? f)
|
||||
(let ((local (env-merge (lambda-closure f) env)))
|
||||
(env-set! local (first (lambda-params f)) i)
|
||||
(env-set! local (nth (lambda-params f) 1) item)
|
||||
(aser (lambda-body f) local))
|
||||
(cek-call f (list i item))))
|
||||
coll))
|
||||
|
||||
;; for-each — evaluate for side effects, aser each body
|
||||
(= name "for-each")
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env)))
|
||||
(results (list)))
|
||||
(for-each (fn (item)
|
||||
(if (lambda? f)
|
||||
(let ((local (env-merge (lambda-closure f) env)))
|
||||
(env-set! local (first (lambda-params f)) item)
|
||||
(append! results (aser (lambda-body f) local)))
|
||||
(cek-call f (list item))))
|
||||
coll)
|
||||
(if (empty? results) nil results))
|
||||
|
||||
;; defisland — evaluate AND serialize (client needs the definition)
|
||||
(= name "defisland")
|
||||
(do (trampoline (eval-expr expr env))
|
||||
(serialize expr))
|
||||
|
||||
;; Definition forms — evaluate for side effects
|
||||
(or (= name "define") (= name "defcomp") (= name "defmacro")
|
||||
(= name "defstyle") (= name "defhandler") (= name "defpage")
|
||||
(= name "defquery") (= name "defaction") (= name "defrelation")
|
||||
(= name "deftype") (= name "defeffect"))
|
||||
(do (trampoline (eval-expr expr env)) nil)
|
||||
|
||||
;; scope — unified render-time dynamic scope
|
||||
(= name "scope")
|
||||
(let ((scope-name (trampoline (eval-expr (first args) env)))
|
||||
(rest-args (rest args))
|
||||
(scope-val nil)
|
||||
(body-args nil))
|
||||
;; Check for :value keyword
|
||||
(if (and (>= (len rest-args) 2)
|
||||
(= (type-of (first rest-args)) "keyword")
|
||||
(= (keyword-name (first rest-args)) "value"))
|
||||
(do (set! scope-val (trampoline (eval-expr (nth rest-args 1) env)))
|
||||
(set! body-args (slice rest-args 2)))
|
||||
(set! body-args rest-args))
|
||||
(scope-push! scope-name scope-val)
|
||||
(let ((result nil))
|
||||
(for-each (fn (body) (set! result (aser body env)))
|
||||
body-args)
|
||||
(scope-pop! scope-name)
|
||||
result))
|
||||
|
||||
;; provide — sugar for scope with value
|
||||
(= name "provide")
|
||||
(let ((prov-name (trampoline (eval-expr (first args) env)))
|
||||
(prov-val (trampoline (eval-expr (nth args 1) env)))
|
||||
(result nil))
|
||||
(scope-push! prov-name prov-val)
|
||||
(for-each (fn (body) (set! result (aser body env)))
|
||||
(slice args 2))
|
||||
(scope-pop! prov-name)
|
||||
result)
|
||||
|
||||
;; Everything else — evaluate normally
|
||||
:else
|
||||
(trampoline (eval-expr expr env))))))
|
||||
|
||||
|
||||
;; Helper: case dispatch for aser mode
|
||||
(define eval-case-aser :effects [render]
|
||||
(fn (match-val (clauses :as list) (env :as dict))
|
||||
(if (< (len clauses) 2)
|
||||
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"))))
|
||||
(aser body env)
|
||||
(if (= match-val (trampoline (eval-expr test env)))
|
||||
(aser body env)
|
||||
(eval-case-aser match-val (slice clauses 2) env)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — SX wire adapter
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; From eval.sx:
|
||||
;; eval-expr, trampoline, call-lambda, expand-macro
|
||||
;; env-has?, env-get, env-set!, env-merge, callable?, lambda?, component?,
|
||||
;; macro?, island?, primitive?, get-primitive, component-name
|
||||
;; lambda-closure, lambda-params, lambda-body
|
||||
;;
|
||||
;; From render.sx:
|
||||
;; HTML_TAGS, eval-cond, process-bindings
|
||||
;;
|
||||
;; From parser.sx:
|
||||
;; serialize (= sx-serialize)
|
||||
;;
|
||||
;; From signals.sx (optional):
|
||||
;; invoke
|
||||
;; --------------------------------------------------------------------------
|
||||
552
web/boot.sx
Normal file
552
web/boot.sx
Normal file
@@ -0,0 +1,552 @@
|
||||
;; ==========================================================================
|
||||
;; boot.sx — Browser boot, mount, hydrate, script processing
|
||||
;;
|
||||
;; Handles the browser startup lifecycle:
|
||||
;; 1. CSS tracking init
|
||||
;; 2. Component script processing (from <script type="text/sx">)
|
||||
;; 3. Hydration of [data-sx] elements
|
||||
;; 4. Engine element processing
|
||||
;;
|
||||
;; Also provides the public mounting/hydration API:
|
||||
;; mount, hydrate, update, render-component
|
||||
;;
|
||||
;; Depends on:
|
||||
;; orchestration.sx — process-elements, engine-init
|
||||
;; adapter-dom.sx — render-to-dom
|
||||
;; render.sx — shared registries
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Head element hoisting (full version)
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Moves <meta>, <title>, <link rel=canonical>, <script type=application/ld+json>
|
||||
;; from rendered content to <head>, deduplicating as needed.
|
||||
|
||||
(define HEAD_HOIST_SELECTOR
|
||||
"meta, title, link[rel='canonical'], script[type='application/ld+json']")
|
||||
|
||||
(define hoist-head-elements-full :effects [mutation io]
|
||||
(fn (root)
|
||||
(let ((els (dom-query-all root HEAD_HOIST_SELECTOR)))
|
||||
(for-each
|
||||
(fn (el)
|
||||
(let ((tag (lower (dom-tag-name el))))
|
||||
(cond
|
||||
;; <title> — replace document title
|
||||
(= tag "title")
|
||||
(do
|
||||
(set-document-title (dom-text-content el))
|
||||
(dom-remove-child (dom-parent el) el))
|
||||
|
||||
;; <meta> — deduplicate by name or property
|
||||
(= tag "meta")
|
||||
(do
|
||||
(let ((name (dom-get-attr el "name"))
|
||||
(prop (dom-get-attr el "property")))
|
||||
(when name
|
||||
(remove-head-element (str "meta[name=\"" name "\"]")))
|
||||
(when prop
|
||||
(remove-head-element (str "meta[property=\"" prop "\"]"))))
|
||||
(dom-remove-child (dom-parent el) el)
|
||||
(dom-append-to-head el))
|
||||
|
||||
;; <link rel=canonical> — deduplicate
|
||||
(and (= tag "link")
|
||||
(= (dom-get-attr el "rel") "canonical"))
|
||||
(do
|
||||
(remove-head-element "link[rel=\"canonical\"]")
|
||||
(dom-remove-child (dom-parent el) el)
|
||||
(dom-append-to-head el))
|
||||
|
||||
;; Everything else (ld+json, etc.) — just move
|
||||
:else
|
||||
(do
|
||||
(dom-remove-child (dom-parent el) el)
|
||||
(dom-append-to-head el)))))
|
||||
els))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Mount — render SX source into a DOM element
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sx-mount :effects [mutation io]
|
||||
(fn (target (source :as string) (extra-env :as dict))
|
||||
;; Render SX source string into target element.
|
||||
;; target: Element or CSS selector string
|
||||
;; source: SX source string
|
||||
;; extra-env: optional extra bindings dict
|
||||
(let ((el (resolve-mount-target target)))
|
||||
(when el
|
||||
(let ((node (sx-render-with-env source extra-env)))
|
||||
(dom-set-text-content el "")
|
||||
(dom-append el node)
|
||||
;; Hoist head elements from rendered content
|
||||
(hoist-head-elements-full el)
|
||||
;; Process sx- attributes, hydrate data-sx and islands
|
||||
(process-elements el)
|
||||
(sx-hydrate-elements el)
|
||||
(sx-hydrate-islands el)
|
||||
(run-post-render-hooks))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Resolve Suspense — replace streaming placeholder with resolved content
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Called by inline <script> tags that arrive during chunked transfer:
|
||||
;; __sxResolve("content", "(~article :title \"Hello\")")
|
||||
;;
|
||||
;; Finds the suspense wrapper by data-suspense attribute, renders the
|
||||
;; new SX content, and replaces the wrapper's children.
|
||||
|
||||
(define resolve-suspense :effects [mutation io]
|
||||
(fn ((id :as string) (sx :as string))
|
||||
;; Process any new <script type="text/sx"> tags that arrived via
|
||||
;; streaming (e.g. extra component defs) before resolving.
|
||||
(process-sx-scripts nil)
|
||||
(let ((el (dom-query (str "[data-suspense=\"" id "\"]"))))
|
||||
(if el
|
||||
(do
|
||||
;; parse returns a list of expressions — render each individually
|
||||
;; (mirroring the public render() API).
|
||||
(let ((exprs (parse sx))
|
||||
(env (get-render-env nil)))
|
||||
(dom-set-text-content el "")
|
||||
(for-each (fn (expr)
|
||||
(dom-append el (render-to-dom expr env nil)))
|
||||
exprs)
|
||||
(process-elements el)
|
||||
(sx-hydrate-elements el)
|
||||
(sx-hydrate-islands el)
|
||||
(run-post-render-hooks)
|
||||
(dom-dispatch el "sx:resolved" {:id id})))
|
||||
(log-warn (str "resolveSuspense: no element for id=" id))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Hydrate — render all [data-sx] elements
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sx-hydrate-elements :effects [mutation io]
|
||||
(fn (root)
|
||||
;; Find all [data-sx] elements within root and render them.
|
||||
(let ((els (dom-query-all (or root (dom-body)) "[data-sx]")))
|
||||
(for-each
|
||||
(fn (el)
|
||||
(when (not (is-processed? el "hydrated"))
|
||||
(mark-processed! el "hydrated")
|
||||
(sx-update-element el nil)))
|
||||
els))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Update — re-render a [data-sx] element with new env data
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sx-update-element :effects [mutation io]
|
||||
(fn (el new-env)
|
||||
;; Re-render a [data-sx] element.
|
||||
;; Reads source from data-sx attr, base env from data-sx-env attr.
|
||||
(let ((target (resolve-mount-target el)))
|
||||
(when target
|
||||
(let ((source (dom-get-attr target "data-sx")))
|
||||
(when source
|
||||
(let ((base-env (parse-env-attr target))
|
||||
(env (merge-envs base-env new-env)))
|
||||
(let ((node (sx-render-with-env source env)))
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target node)
|
||||
;; Update stored env if new-env provided
|
||||
(when new-env
|
||||
(store-env-attr target base-env new-env))))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Render component — build synthetic call from kwargs dict
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sx-render-component :effects [mutation io]
|
||||
(fn ((name :as string) (kwargs :as dict) (extra-env :as dict))
|
||||
;; Render a named component with keyword args.
|
||||
;; name: component name (with or without ~ prefix)
|
||||
;; kwargs: dict of param-name → value
|
||||
;; extra-env: optional extra env bindings
|
||||
(let ((full-name (if (starts-with? name "~") name (str "~" name))))
|
||||
(let ((env (get-render-env extra-env))
|
||||
(comp (env-get env full-name)))
|
||||
(if (not (component? comp))
|
||||
(error (str "Unknown component: " full-name))
|
||||
;; Build synthetic call expression
|
||||
(let ((call-expr (list (make-symbol full-name))))
|
||||
(for-each
|
||||
(fn ((k :as string))
|
||||
(append! call-expr (make-keyword (to-kebab k)))
|
||||
(append! call-expr (dict-get kwargs k)))
|
||||
(keys kwargs))
|
||||
(render-to-dom call-expr env nil)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Script processing — <script type="text/sx">
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define process-sx-scripts :effects [mutation io]
|
||||
(fn (root)
|
||||
;; Process all <script type="text/sx"> tags.
|
||||
;; - data-components + data-hash → localStorage cache
|
||||
;; - data-mount="<selector>" → render into target
|
||||
;; - Default: load as components
|
||||
(let ((scripts (query-sx-scripts root)))
|
||||
(for-each
|
||||
(fn (s)
|
||||
(when (not (is-processed? s "script"))
|
||||
(mark-processed! s "script")
|
||||
(let ((text (dom-text-content s)))
|
||||
(cond
|
||||
;; Component definitions
|
||||
(dom-has-attr? s "data-components")
|
||||
(process-component-script s text)
|
||||
|
||||
;; Empty script — skip
|
||||
(or (nil? text) (empty? (trim text)))
|
||||
nil
|
||||
|
||||
;; Init scripts — evaluate SX for side effects (event listeners etc.)
|
||||
(dom-has-attr? s "data-init")
|
||||
(let ((exprs (sx-parse text)))
|
||||
(for-each
|
||||
(fn (expr) (eval-expr expr (env-extend (dict))))
|
||||
exprs))
|
||||
|
||||
;; Mount directive
|
||||
(dom-has-attr? s "data-mount")
|
||||
(let ((mount-sel (dom-get-attr s "data-mount"))
|
||||
(target (dom-query mount-sel)))
|
||||
(when target
|
||||
(sx-mount target text nil)))
|
||||
|
||||
;; Default: load as components
|
||||
:else
|
||||
(sx-load-components text)))))
|
||||
scripts))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Component script with caching
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define process-component-script :effects [mutation io]
|
||||
(fn (script (text :as string))
|
||||
;; Handle <script type="text/sx" data-components data-hash="...">
|
||||
(let ((hash (dom-get-attr script "data-hash")))
|
||||
(if (nil? hash)
|
||||
;; Legacy: no hash — just load inline
|
||||
(when (and text (not (empty? (trim text))))
|
||||
(sx-load-components text))
|
||||
;; Hash-based caching
|
||||
(let ((has-inline (and text (not (empty? (trim text))))))
|
||||
(let ((cached-hash (local-storage-get "sx-components-hash")))
|
||||
(if (= cached-hash hash)
|
||||
;; Cache hit
|
||||
(if has-inline
|
||||
;; Server sent full source (cookie stale) — update cache
|
||||
(do
|
||||
(local-storage-set "sx-components-hash" hash)
|
||||
(local-storage-set "sx-components-src" text)
|
||||
(sx-load-components text)
|
||||
(log-info "components: downloaded (cookie stale)"))
|
||||
;; Server omitted source — load from cache
|
||||
(let ((cached (local-storage-get "sx-components-src")))
|
||||
(if cached
|
||||
(do
|
||||
(sx-load-components cached)
|
||||
(log-info (str "components: cached (" hash ")")))
|
||||
;; Cache entry missing — clear cookie and reload
|
||||
(do
|
||||
(clear-sx-comp-cookie)
|
||||
(browser-reload)))))
|
||||
;; Cache miss — hash mismatch
|
||||
(if has-inline
|
||||
;; Server sent full source — cache it
|
||||
(do
|
||||
(local-storage-set "sx-components-hash" hash)
|
||||
(local-storage-set "sx-components-src" text)
|
||||
(sx-load-components text)
|
||||
(log-info (str "components: downloaded (" hash ")")))
|
||||
;; Server omitted but cache stale — clear and reload
|
||||
(do
|
||||
(local-storage-remove "sx-components-hash")
|
||||
(local-storage-remove "sx-components-src")
|
||||
(clear-sx-comp-cookie)
|
||||
(browser-reload)))))
|
||||
(set-sx-comp-cookie hash))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Page registry for client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define _page-routes (list))
|
||||
|
||||
(define process-page-scripts :effects [mutation io]
|
||||
(fn ()
|
||||
;; Process <script type="text/sx-pages"> tags.
|
||||
;; Parses SX page registry and builds route entries with parsed patterns.
|
||||
(let ((scripts (query-page-scripts)))
|
||||
(log-info (str "pages: found " (len scripts) " script tags"))
|
||||
(for-each
|
||||
(fn (s)
|
||||
(when (not (is-processed? s "pages"))
|
||||
(mark-processed! s "pages")
|
||||
(let ((text (dom-text-content s)))
|
||||
(log-info (str "pages: script text length=" (if text (len text) 0)))
|
||||
(if (and text (not (empty? (trim text))))
|
||||
(let ((pages (parse text)))
|
||||
(log-info (str "pages: parsed " (len pages) " entries"))
|
||||
(for-each
|
||||
(fn ((page :as dict))
|
||||
(append! _page-routes
|
||||
(merge page
|
||||
{"parsed" (parse-route-pattern (get page "path"))})))
|
||||
pages))
|
||||
(log-warn "pages: script tag is empty")))))
|
||||
scripts)
|
||||
(log-info (str "pages: " (len _page-routes) " routes loaded")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Island hydration — activate reactive islands from SSR output
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; The server renders islands as:
|
||||
;; <div data-sx-island="counter" data-sx-state='{"initial": 0}'>
|
||||
;; ...static HTML...
|
||||
;; </div>
|
||||
;;
|
||||
;; Hydration:
|
||||
;; 1. Find all [data-sx-island] elements
|
||||
;; 2. Look up the island component by name
|
||||
;; 3. Parse data-sx-state into kwargs
|
||||
;; 4. Re-render the island body in a reactive context
|
||||
;; 5. Morph existing DOM to preserve structure, focus, scroll
|
||||
;; 6. Store disposers on the element for cleanup
|
||||
|
||||
(define sx-hydrate-islands :effects [mutation io]
|
||||
(fn (root)
|
||||
(let ((els (dom-query-all (or root (dom-body)) "[data-sx-island]")))
|
||||
(for-each
|
||||
(fn (el)
|
||||
(when (not (is-processed? el "island-hydrated"))
|
||||
(mark-processed! el "island-hydrated")
|
||||
(hydrate-island el)))
|
||||
els))))
|
||||
|
||||
(define hydrate-island :effects [mutation io]
|
||||
(fn (el)
|
||||
(let ((name (dom-get-attr el "data-sx-island"))
|
||||
(state-sx (or (dom-get-attr el "data-sx-state") "{}")))
|
||||
(let ((comp-name (str "~" name))
|
||||
(env (get-render-env nil)))
|
||||
(let ((comp (env-get env comp-name)))
|
||||
(if (not (or (component? comp) (island? comp)))
|
||||
(log-warn (str "hydrate-island: unknown island " comp-name))
|
||||
|
||||
;; Parse state and build keyword args — SX format, not JSON
|
||||
(let ((kwargs (or (first (sx-parse state-sx)) {}))
|
||||
(disposers (list))
|
||||
(local (env-merge (component-closure comp) env)))
|
||||
|
||||
;; Bind params from kwargs
|
||||
(for-each
|
||||
(fn ((p :as string))
|
||||
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
||||
(component-params comp))
|
||||
|
||||
;; Render the island body in a reactive scope
|
||||
(let ((body-dom
|
||||
(with-island-scope
|
||||
(fn (disposable) (append! disposers disposable))
|
||||
(fn () (render-to-dom (component-body comp) local nil)))))
|
||||
|
||||
;; Clear existing content and append reactive DOM directly.
|
||||
;; Unlike morph-children, this preserves addEventListener-based
|
||||
;; event handlers on the freshly rendered nodes.
|
||||
(dom-set-text-content el "")
|
||||
(dom-append el body-dom)
|
||||
|
||||
;; Store disposers for cleanup
|
||||
(dom-set-data el "sx-disposers" disposers)
|
||||
|
||||
;; Process any sx- attributes on new content
|
||||
(process-elements el)
|
||||
|
||||
(log-info (str "hydrated island: " comp-name
|
||||
" (" (len disposers) " disposers)"))))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Island disposal — clean up when island removed from DOM
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define dispose-island :effects [mutation io]
|
||||
(fn (el)
|
||||
(let ((disposers (dom-get-data el "sx-disposers")))
|
||||
(when disposers
|
||||
(for-each
|
||||
(fn ((d :as lambda))
|
||||
(when (callable? d) (d)))
|
||||
disposers)
|
||||
(dom-set-data el "sx-disposers" nil)))))
|
||||
|
||||
(define dispose-islands-in :effects [mutation io]
|
||||
(fn (root)
|
||||
;; Dispose islands within root, but SKIP hydrated islands —
|
||||
;; they may be preserved across morphs. Only dispose islands
|
||||
;; that are not currently hydrated (e.g. freshly parsed content
|
||||
;; being discarded) or that have been explicitly detached.
|
||||
(when root
|
||||
(let ((islands (dom-query-all root "[data-sx-island]")))
|
||||
(when (and islands (not (empty? islands)))
|
||||
(let ((to-dispose (filter
|
||||
(fn (el) (not (is-processed? el "island-hydrated")))
|
||||
islands)))
|
||||
(when (not (empty? to-dispose))
|
||||
(log-info (str "disposing " (len to-dispose) " island(s)"))
|
||||
(for-each dispose-island to-dispose))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Render hooks — generic pre/post callbacks for hydration, swap, mount.
|
||||
;; The spec calls these at render boundaries; the app decides what to do.
|
||||
;; Pre-render: setup before DOM changes (e.g. prepare state).
|
||||
;; Post-render: cleanup after DOM changes (e.g. flush collected CSS).
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define *pre-render-hooks* (list))
|
||||
(define *post-render-hooks* (list))
|
||||
|
||||
(define register-pre-render-hook :effects [mutation]
|
||||
(fn ((hook-fn :as lambda))
|
||||
(append! *pre-render-hooks* hook-fn)))
|
||||
|
||||
(define register-post-render-hook :effects [mutation]
|
||||
(fn ((hook-fn :as lambda))
|
||||
(append! *post-render-hooks* hook-fn)))
|
||||
|
||||
(define run-pre-render-hooks :effects [mutation io]
|
||||
(fn ()
|
||||
(for-each (fn (hook) (cek-call hook nil)) *pre-render-hooks*)))
|
||||
|
||||
(define run-post-render-hooks :effects [mutation io]
|
||||
(fn ()
|
||||
(log-info "run-post-render-hooks:" (len *post-render-hooks*) "hooks")
|
||||
(for-each (fn (hook)
|
||||
(log-info " hook type:" (type-of hook) "callable:" (callable? hook) "lambda:" (lambda? hook))
|
||||
(cek-call hook nil))
|
||||
*post-render-hooks*)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Full boot sequence
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define boot-init :effects [mutation io]
|
||||
(fn ()
|
||||
;; Full browser initialization:
|
||||
;; 1. CSS tracking
|
||||
;; 2. Style dictionary
|
||||
;; 3. Process scripts (components + mounts)
|
||||
;; 4. Process page registry (client-side routing)
|
||||
;; 5. Hydrate [data-sx] elements
|
||||
;; 6. Hydrate [data-sx-island] elements (reactive islands)
|
||||
;; 7. Process engine elements
|
||||
(do
|
||||
(log-info (str "sx-browser " SX_VERSION))
|
||||
(init-css-tracking)
|
||||
(process-page-scripts)
|
||||
(process-sx-scripts nil)
|
||||
(sx-hydrate-elements nil)
|
||||
(sx-hydrate-islands nil)
|
||||
(run-post-render-hooks)
|
||||
(process-elements nil))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — Boot
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; From orchestration.sx:
|
||||
;; process-elements, init-css-tracking
|
||||
;;
|
||||
;; === DOM / Render ===
|
||||
;; (resolve-mount-target target) → Element (string → querySelector, else identity)
|
||||
;; (sx-render-with-env source extra-env) → DOM node (parse + render with componentEnv + extra)
|
||||
;; (get-render-env extra-env) → merged component env + extra
|
||||
;; (merge-envs base new) → merged env dict
|
||||
;; (render-to-dom expr env ns) → DOM node
|
||||
;; (sx-load-components text) → void (parse + eval into componentEnv)
|
||||
;;
|
||||
;; === DOM queries ===
|
||||
;; (dom-query sel) → Element or nil
|
||||
;; (dom-query-all root sel) → list of Elements
|
||||
;; (dom-body) → document.body
|
||||
;; (dom-get-attr el name) → string or nil
|
||||
;; (dom-has-attr? el name) → boolean
|
||||
;; (dom-text-content el) → string
|
||||
;; (dom-set-text-content el s) → void
|
||||
;; (dom-append el child) → void
|
||||
;; (dom-remove-child parent el) → void
|
||||
;; (dom-parent el) → Element
|
||||
;; (dom-append-to-head el) → void
|
||||
;; (dom-tag-name el) → string
|
||||
;;
|
||||
;; === Head hoisting ===
|
||||
;; (set-document-title s) → void (document.title = s)
|
||||
;; (remove-head-element sel) → void (remove matching element from <head>)
|
||||
;;
|
||||
;; === Script queries ===
|
||||
;; (query-sx-scripts root) → list of <script type="text/sx"> elements
|
||||
;; (query-page-scripts) → list of <script type="text/sx-pages"> elements
|
||||
;;
|
||||
;; === localStorage ===
|
||||
;; (local-storage-get key) → string or nil
|
||||
;; (local-storage-set key val) → void
|
||||
;; (local-storage-remove key) → void
|
||||
;;
|
||||
;; === Cookies ===
|
||||
;; (set-sx-comp-cookie hash) → void
|
||||
;; (clear-sx-comp-cookie) → void
|
||||
;;
|
||||
;; === Env ===
|
||||
;; (parse-env-attr el) → dict (parse data-sx-env JSON attr)
|
||||
;; (store-env-attr el base new) → void (merge and store back as JSON)
|
||||
;; (to-kebab s) → string (underscore → kebab-case)
|
||||
;;
|
||||
;; === Logging ===
|
||||
;; (log-info msg) → void (console.log with prefix)
|
||||
;; (log-parse-error label text err) → void (diagnostic parse error)
|
||||
;;
|
||||
;; === Parsing (island state) ===
|
||||
;; (sx-parse str) → list of AST expressions (from parser.sx)
|
||||
;;
|
||||
;; === Processing markers ===
|
||||
;; (mark-processed! el key) → void
|
||||
;; (is-processed? el key) → boolean
|
||||
;;
|
||||
;; === Morph ===
|
||||
;; (morph-children target source) → void (morph target's children to match source)
|
||||
;;
|
||||
;; === Island support (from adapter-dom.sx / signals.sx) ===
|
||||
;; (island? x) → boolean
|
||||
;; (component-closure comp) → env
|
||||
;; (component-params comp) → list of param names
|
||||
;; (component-body comp) → AST
|
||||
;; (component-name comp) → string
|
||||
;; (component-has-children? comp) → boolean
|
||||
;; (with-island-scope scope-fn body-fn) → result (track disposables)
|
||||
;; (render-to-dom expr env ns) → DOM node
|
||||
;; (dom-get-data el key) → any (from el._sxData)
|
||||
;; (dom-set-data el key val) → void
|
||||
;; --------------------------------------------------------------------------
|
||||
414
web/boundary-web.sx
Normal file
414
web/boundary-web.sx
Normal file
@@ -0,0 +1,414 @@
|
||||
;; ==========================================================================
|
||||
;; boundary-web.sx — Web platform boundary contract
|
||||
;;
|
||||
;; I/O primitives, signals, spreads, scopes, and page helpers
|
||||
;; required by the SX web framework. Built on the core spec.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 2: Core I/O primitives — async, side-effectful, need host context
|
||||
;;
|
||||
;; These are generic web-platform I/O that any SX web host would provide,
|
||||
;; regardless of deployment architecture.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Request context
|
||||
|
||||
(define-io-primitive "current-user"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:effects [io]
|
||||
:async true
|
||||
:doc "Current authenticated user dict, or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-arg"
|
||||
:params (name &rest default)
|
||||
:returns "any"
|
||||
:effects [io]
|
||||
:async true
|
||||
:doc "Read a query string argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-path"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:effects [io]
|
||||
:async true
|
||||
:doc "Current request path."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-view-args"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:effects [io]
|
||||
:async true
|
||||
:doc "Read a URL view argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "csrf-token"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:effects [io]
|
||||
:async true
|
||||
:doc "Current CSRF token string."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "abort"
|
||||
:params (status &rest message)
|
||||
:returns "nil"
|
||||
:effects [io]
|
||||
:async true
|
||||
:doc "Raise HTTP error from SX."
|
||||
:context :request)
|
||||
|
||||
;; Routing
|
||||
|
||||
(define-io-primitive "url-for"
|
||||
:params (endpoint &key)
|
||||
:returns "string"
|
||||
:effects [io]
|
||||
:async true
|
||||
:doc "Generate URL for a named endpoint."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "route-prefix"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:effects [io]
|
||||
:async true
|
||||
:doc "Service URL prefix for dev/prod routing."
|
||||
:context :request)
|
||||
|
||||
;; Config and host context (sync — no await needed)
|
||||
|
||||
(define-io-primitive "app-url"
|
||||
:params (service &rest path)
|
||||
:returns "string"
|
||||
:effects [io]
|
||||
:async false
|
||||
:doc "Full URL for a service: (app-url \"blog\" \"/my-post/\")."
|
||||
:context :config)
|
||||
|
||||
(define-io-primitive "asset-url"
|
||||
:params (&rest path)
|
||||
:returns "string"
|
||||
:effects [io]
|
||||
:async false
|
||||
:doc "Versioned static asset URL."
|
||||
:context :config)
|
||||
|
||||
(define-io-primitive "config"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:effects [io]
|
||||
:async false
|
||||
:doc "Read a value from host configuration."
|
||||
:context :config)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Boundary types — what's allowed to cross the host-SX boundary
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-boundary-types
|
||||
(list "number" "string" "boolean" "nil" "keyword"
|
||||
"list" "dict" "sx-source"))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Web interop — reading non-SX request formats
|
||||
;;
|
||||
;; SX's native wire format is SX (text/sx). These primitives bridge to
|
||||
;; legacy web formats: HTML form encoding, JSON bodies, HTTP headers.
|
||||
;; They're useful for interop but not fundamental to SX-to-SX communication.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-io-primitive "now"
|
||||
:params (&rest format)
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Current timestamp. Optional format string (strftime). Default ISO 8601."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "sleep"
|
||||
:params (ms)
|
||||
:returns "nil"
|
||||
:async true
|
||||
:doc "Pause execution for ms milliseconds. For demos and testing."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-form"
|
||||
:params (name &rest default)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a form field from a POST/PUT/PATCH request body."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-json"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:async true
|
||||
:doc "Read JSON body from the current request, or nil if not JSON."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-header"
|
||||
:params (name &rest default)
|
||||
:returns "string?"
|
||||
:async true
|
||||
:doc "Read a request header value by name."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-content-type"
|
||||
:params ()
|
||||
:returns "string?"
|
||||
:async true
|
||||
:doc "Content-Type of the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-args-all"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "All query string parameters as a dict."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-form-all"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "All form fields as a dict."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-form-list"
|
||||
:params (field-name)
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "All values for a multi-value form field as a list."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-headers-all"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "All request headers as a dict (lowercase keys)."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-file-name"
|
||||
:params (field-name)
|
||||
:returns "string?"
|
||||
:async true
|
||||
:doc "Filename of an uploaded file by field name, or nil."
|
||||
:context :request)
|
||||
|
||||
;; Response manipulation
|
||||
|
||||
(define-io-primitive "set-response-header"
|
||||
:params (name value)
|
||||
:returns "nil"
|
||||
:async true
|
||||
:doc "Set a response header. Applied after handler returns."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "set-response-status"
|
||||
:params (status)
|
||||
:returns "nil"
|
||||
:async true
|
||||
:doc "Set the HTTP response status code. Applied after handler returns."
|
||||
:context :request)
|
||||
|
||||
;; Ephemeral state — per-process, resets on restart
|
||||
|
||||
(define-io-primitive "state-get"
|
||||
:params (key &rest default)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read from ephemeral per-process state dict."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "state-set!"
|
||||
:params (key value)
|
||||
:returns "nil"
|
||||
:async true
|
||||
:doc "Write to ephemeral per-process state dict."
|
||||
:context :request)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 3: Signal primitives — reactive state for islands
|
||||
;;
|
||||
;; These are pure primitives (no IO) but are separated from primitives.sx
|
||||
;; because they introduce a new type (signal) and depend on signals.sx.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(declare-tier :signals :source "signals.sx")
|
||||
|
||||
(declare-signal-primitive "signal"
|
||||
:params (initial-value)
|
||||
:returns "signal"
|
||||
:effects []
|
||||
:doc "Create a reactive signal container with an initial value.")
|
||||
|
||||
(declare-signal-primitive "deref"
|
||||
:params (signal)
|
||||
:returns "any"
|
||||
:effects []
|
||||
:doc "Read a signal's current value. In a reactive context (inside an island),
|
||||
subscribes the current DOM binding to the signal. Outside reactive
|
||||
context, just returns the value.")
|
||||
|
||||
(declare-signal-primitive "reset!"
|
||||
:params (signal value)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Set a signal to a new value. Notifies all subscribers.")
|
||||
|
||||
(declare-signal-primitive "swap!"
|
||||
:params (signal f &rest args)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Update a signal by applying f to its current value. (swap! s inc)
|
||||
is equivalent to (reset! s (inc (deref s))) but atomic.")
|
||||
|
||||
(declare-signal-primitive "computed"
|
||||
:params (compute-fn)
|
||||
:returns "signal"
|
||||
:effects []
|
||||
:doc "Create a derived signal that recomputes when its dependencies change.
|
||||
Dependencies are discovered automatically by tracking deref calls.")
|
||||
|
||||
(declare-signal-primitive "effect"
|
||||
:params (effect-fn)
|
||||
:returns "lambda"
|
||||
:effects [mutation]
|
||||
:doc "Run a side effect that re-runs when its signal dependencies change.
|
||||
Returns a dispose function. If the effect function returns a function,
|
||||
it is called as cleanup before the next run.")
|
||||
|
||||
(declare-signal-primitive "batch"
|
||||
:params (thunk)
|
||||
:returns "any"
|
||||
:effects [mutation]
|
||||
:doc "Group multiple signal writes. Subscribers are notified once at the end,
|
||||
after all values have been updated.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 4: Spread + Collect — render-time attribute injection and accumulation
|
||||
;;
|
||||
;; `spread` is a new type: a dict of attributes that, when returned as a child
|
||||
;; of an HTML element, merges its attrs onto the parent element rather than
|
||||
;; rendering as content. This enables components like `~cssx/tw` to inject
|
||||
;; classes and styles onto their parent from inside the child list.
|
||||
;;
|
||||
;; `collect!` / `collected` are render-time accumulators. Values are collected
|
||||
;; into named buckets (with deduplication) during rendering and retrieved at
|
||||
;; flush points (e.g. a single <style> tag for all collected CSS rules).
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(declare-tier :spread :source "render.sx")
|
||||
|
||||
(declare-spread-primitive "make-spread"
|
||||
:params (attrs)
|
||||
:returns "spread"
|
||||
:effects []
|
||||
:doc "Create a spread value from an attrs dict. When this value appears as
|
||||
a child of an HTML element, its attrs are merged onto the parent
|
||||
element (class values joined, others overwritten).")
|
||||
|
||||
(declare-spread-primitive "spread?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:effects []
|
||||
:doc "Test whether a value is a spread.")
|
||||
|
||||
(declare-spread-primitive "spread-attrs"
|
||||
:params (s)
|
||||
:returns "dict"
|
||||
:effects []
|
||||
:doc "Extract the attrs dict from a spread value.")
|
||||
|
||||
(declare-spread-primitive "collect!"
|
||||
:params (bucket value)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Add value to a named render-time accumulator bucket. Values are
|
||||
deduplicated (no duplicates added). Buckets persist for the duration
|
||||
of the current render pass.")
|
||||
|
||||
(declare-spread-primitive "collected"
|
||||
:params (bucket)
|
||||
:returns "list"
|
||||
:effects []
|
||||
:doc "Return all values collected in the named bucket during the current
|
||||
render pass. Returns an empty list if the bucket doesn't exist.")
|
||||
|
||||
(declare-spread-primitive "clear-collected!"
|
||||
:params (bucket)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Clear a named render-time accumulator bucket. Used at flush points
|
||||
after emitting collected values (e.g. after writing a <style> tag).")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 5: Scoped effects — unified render-time dynamic scope
|
||||
;;
|
||||
;; `scope` is the general primitive. `provide` is sugar for scope-with-value.
|
||||
;; Both `provide` and `scope` are special forms in the evaluator.
|
||||
;;
|
||||
;; The platform must implement per-name stacks. Each entry has a value,
|
||||
;; an emitted list, and a dedup flag. `scope-push!`/`scope-pop!` manage
|
||||
;; the stack. `provide-push!`/`provide-pop!` are aliases.
|
||||
;;
|
||||
;; `collect!`/`collected`/`clear-collected!` (Tier 4) are backed by scopes:
|
||||
;; collect! lazily creates a root scope with dedup=true, then emits into it.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(declare-tier :scoped-effects :source "eval.sx")
|
||||
|
||||
(declare-spread-primitive "scope-push!"
|
||||
:params (name value)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Push a scope with name and value. General form — provide-push! is an alias.")
|
||||
|
||||
(declare-spread-primitive "scope-pop!"
|
||||
:params (name)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Pop the most recent scope for name. General form — provide-pop! is an alias.")
|
||||
|
||||
(declare-spread-primitive "provide-push!"
|
||||
:params (name value)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Alias for scope-push!. Push a scope with name and value.")
|
||||
|
||||
(declare-spread-primitive "provide-pop!"
|
||||
:params (name)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Alias for scope-pop!. Pop the most recent scope for name.")
|
||||
|
||||
(declare-spread-primitive "context"
|
||||
:params (name &rest default)
|
||||
:returns "any"
|
||||
:effects []
|
||||
:doc "Read value from nearest enclosing provide with matching name.
|
||||
Errors if no provider and no default given.")
|
||||
|
||||
(declare-spread-primitive "emit!"
|
||||
:params (name value)
|
||||
:returns "nil"
|
||||
:effects [mutation]
|
||||
:doc "Append value to nearest enclosing provide's accumulator.
|
||||
Errors if no matching provider. No deduplication.")
|
||||
|
||||
(declare-spread-primitive "emitted"
|
||||
:params (name)
|
||||
:returns "list"
|
||||
:effects []
|
||||
:doc "Return list of values emitted into nearest matching provider.
|
||||
Empty list if no provider.")
|
||||
459
web/deps.sx
Normal file
459
web/deps.sx
Normal file
@@ -0,0 +1,459 @@
|
||||
;; ==========================================================================
|
||||
;; deps.sx — Component dependency analysis specification
|
||||
;;
|
||||
;; Pure functions for analyzing component dependency graphs.
|
||||
;; Used by the bundling system to compute per-page component bundles
|
||||
;; instead of sending every definition to every page.
|
||||
;;
|
||||
;; All functions are pure — no IO, no platform-specific operations.
|
||||
;; Each host bootstraps this to native code alongside eval.sx/render.sx.
|
||||
;;
|
||||
;; From eval.sx platform (already provided by every host):
|
||||
;; (type-of x) → type string
|
||||
;; (symbol-name s) → string name of symbol
|
||||
;; (component-body c) → unevaluated AST of component body
|
||||
;; (component-name c) → string name (without ~)
|
||||
;; (macro-body m) → macro body AST
|
||||
;; (env-get env k) → value or nil
|
||||
;;
|
||||
;; New platform functions for deps (each host implements):
|
||||
;; (component-deps c) → cached deps list (may be empty)
|
||||
;; (component-set-deps! c d)→ cache deps on component
|
||||
;; (component-css-classes c)→ pre-scanned CSS class list
|
||||
;; (regex-find-all pat src) → list of capture group 1 matches
|
||||
;; (scan-css-classes src) → list of CSS class strings from source
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. AST scanning — collect ~component references from an AST node
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Walks all branches of control flow (if/when/cond/case) to find
|
||||
;; every component that *could* be rendered.
|
||||
|
||||
(define scan-refs :effects []
|
||||
(fn (node)
|
||||
(let ((refs (list)))
|
||||
(scan-refs-walk node refs)
|
||||
refs)))
|
||||
|
||||
|
||||
(define scan-refs-walk :effects []
|
||||
(fn (node (refs :as list))
|
||||
(cond
|
||||
;; Symbol starting with ~ → component reference
|
||||
(= (type-of node) "symbol")
|
||||
(let ((name (symbol-name node)))
|
||||
(when (starts-with? name "~")
|
||||
(when (not (contains? refs name))
|
||||
(append! refs name))))
|
||||
|
||||
;; List → recurse into all elements (covers all control flow branches)
|
||||
(= (type-of node) "list")
|
||||
(for-each (fn (item) (scan-refs-walk item refs)) node)
|
||||
|
||||
;; Dict → recurse into values
|
||||
(= (type-of node) "dict")
|
||||
(for-each (fn (key) (scan-refs-walk (dict-get node key) refs))
|
||||
(keys node))
|
||||
|
||||
;; Literals (number, string, boolean, nil, keyword) → no refs
|
||||
:else nil)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Transitive dependency closure
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Given a component name and an environment, compute all components
|
||||
;; that it can transitively render. Handles cycles via seen-set.
|
||||
|
||||
(define transitive-deps-walk :effects []
|
||||
(fn ((n :as string) (seen :as list) (env :as dict))
|
||||
(when (not (contains? seen n))
|
||||
(append! seen n)
|
||||
(let ((val (env-get env n)))
|
||||
(cond
|
||||
(or (= (type-of val) "component") (= (type-of val) "island"))
|
||||
(for-each (fn ((ref :as string)) (transitive-deps-walk ref seen env))
|
||||
(scan-refs (component-body val)))
|
||||
(= (type-of val) "macro")
|
||||
(for-each (fn ((ref :as string)) (transitive-deps-walk ref seen env))
|
||||
(scan-refs (macro-body val)))
|
||||
:else nil)))))
|
||||
|
||||
|
||||
(define transitive-deps :effects []
|
||||
(fn ((name :as string) (env :as dict))
|
||||
(let ((seen (list))
|
||||
(key (if (starts-with? name "~") name (str "~" name))))
|
||||
(transitive-deps-walk key seen env)
|
||||
(filter (fn ((x :as string)) (not (= x key))) seen))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Compute deps for all components in an environment
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Iterates env, calls transitive-deps for each component, and
|
||||
;; stores the result via the platform's component-set-deps! function.
|
||||
;;
|
||||
;; Platform interface:
|
||||
;; (env-components env) → list of component names in env
|
||||
;; (component-set-deps! comp deps) → store deps on component
|
||||
|
||||
(define compute-all-deps :effects [mutation]
|
||||
(fn ((env :as dict))
|
||||
(for-each
|
||||
(fn ((name :as string))
|
||||
(let ((val (env-get env name)))
|
||||
(when (or (= (type-of val) "component") (= (type-of val) "island"))
|
||||
(component-set-deps! val (transitive-deps name env)))))
|
||||
(env-components env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Scan serialized SX source for component references
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Regex-based extraction of (~name patterns from SX wire format.
|
||||
;; Returns list of names WITH ~ prefix.
|
||||
;;
|
||||
;; Platform interface:
|
||||
;; (regex-find-all pattern source) → list of matched group strings
|
||||
|
||||
(define scan-components-from-source :effects []
|
||||
(fn ((source :as string))
|
||||
(let ((matches (regex-find-all "\\(~([a-zA-Z_][a-zA-Z0-9_\\-:/]*)" source)))
|
||||
(map (fn ((m :as string)) (str "~" m)) matches))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Components needed for a page
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Scans page source for direct component references, then computes
|
||||
;; the transitive closure. Returns list of ~names.
|
||||
|
||||
(define components-needed :effects []
|
||||
(fn ((page-source :as string) (env :as dict))
|
||||
(let ((direct (scan-components-from-source page-source))
|
||||
(all-needed (list)))
|
||||
|
||||
;; Add each direct ref + its transitive deps
|
||||
(for-each
|
||||
(fn ((name :as string))
|
||||
(when (not (contains? all-needed name))
|
||||
(append! all-needed name))
|
||||
(let ((val (env-get env name)))
|
||||
(let ((deps (if (and (= (type-of val) "component")
|
||||
(not (empty? (component-deps val))))
|
||||
(component-deps val)
|
||||
(transitive-deps name env))))
|
||||
(for-each
|
||||
(fn ((dep :as string))
|
||||
(when (not (contains? all-needed dep))
|
||||
(append! all-needed dep)))
|
||||
deps))))
|
||||
direct)
|
||||
|
||||
all-needed)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Build per-page component bundle
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Given page source and env, returns list of component names needed.
|
||||
;; The host uses this list to serialize only the needed definitions
|
||||
;; and compute a page-specific hash.
|
||||
;;
|
||||
;; This replaces the "send everything" approach with per-page bundles.
|
||||
|
||||
(define page-component-bundle :effects []
|
||||
(fn ((page-source :as string) (env :as dict))
|
||||
(components-needed page-source env)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. CSS classes for a page
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns the union of CSS classes from components this page uses,
|
||||
;; plus classes from the page source itself.
|
||||
;;
|
||||
;; Platform interface:
|
||||
;; (component-css-classes c) → set/list of class strings
|
||||
;; (scan-css-classes source) → set/list of class strings from source
|
||||
|
||||
(define page-css-classes :effects []
|
||||
(fn ((page-source :as string) (env :as dict))
|
||||
(let ((needed (components-needed page-source env))
|
||||
(classes (list)))
|
||||
|
||||
;; Collect classes from needed components
|
||||
(for-each
|
||||
(fn ((name :as string))
|
||||
(let ((val (env-get env name)))
|
||||
(when (= (type-of val) "component")
|
||||
(for-each
|
||||
(fn ((cls :as string))
|
||||
(when (not (contains? classes cls))
|
||||
(append! classes cls)))
|
||||
(component-css-classes val)))))
|
||||
needed)
|
||||
|
||||
;; Add classes from page source
|
||||
(for-each
|
||||
(fn ((cls :as string))
|
||||
(when (not (contains? classes cls))
|
||||
(append! classes cls)))
|
||||
(scan-css-classes page-source))
|
||||
|
||||
classes)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. IO detection — scan component ASTs for IO primitive references
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Extends the dependency walker to detect references to IO primitives.
|
||||
;; IO names are provided by the host (from boundary.sx declarations).
|
||||
;; A component is "pure" if it (transitively) references no IO primitives.
|
||||
;;
|
||||
;; Platform interface additions:
|
||||
;; (component-io-refs c) → cached IO ref list (may be empty)
|
||||
;; (component-set-io-refs! c r) → cache IO refs on component
|
||||
|
||||
(define scan-io-refs-walk :effects []
|
||||
(fn (node (io-names :as list) (refs :as list))
|
||||
(cond
|
||||
;; Symbol → check if name is in the IO set
|
||||
(= (type-of node) "symbol")
|
||||
(let ((name (symbol-name node)))
|
||||
(when (contains? io-names name)
|
||||
(when (not (contains? refs name))
|
||||
(append! refs name))))
|
||||
|
||||
;; List → recurse into all elements
|
||||
(= (type-of node) "list")
|
||||
(for-each (fn (item) (scan-io-refs-walk item io-names refs)) node)
|
||||
|
||||
;; Dict → recurse into values
|
||||
(= (type-of node) "dict")
|
||||
(for-each (fn (key) (scan-io-refs-walk (dict-get node key) io-names refs))
|
||||
(keys node))
|
||||
|
||||
;; Literals → no IO refs
|
||||
:else nil)))
|
||||
|
||||
|
||||
(define scan-io-refs :effects []
|
||||
(fn (node (io-names :as list))
|
||||
(let ((refs (list)))
|
||||
(scan-io-refs-walk node io-names refs)
|
||||
refs)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. Transitive IO refs — follow component deps and union IO refs
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define transitive-io-refs-walk :effects []
|
||||
(fn ((n :as string) (seen :as list) (all-refs :as list) (env :as dict) (io-names :as list))
|
||||
(when (not (contains? seen n))
|
||||
(append! seen n)
|
||||
(let ((val (env-get env n)))
|
||||
(cond
|
||||
(= (type-of val) "component")
|
||||
(do
|
||||
;; Scan this component's body for IO refs
|
||||
(for-each
|
||||
(fn ((ref :as string))
|
||||
(when (not (contains? all-refs ref))
|
||||
(append! all-refs ref)))
|
||||
(scan-io-refs (component-body val) io-names))
|
||||
;; Recurse into component deps
|
||||
(for-each
|
||||
(fn ((dep :as string)) (transitive-io-refs-walk dep seen all-refs env io-names))
|
||||
(scan-refs (component-body val))))
|
||||
|
||||
(= (type-of val) "macro")
|
||||
(do
|
||||
(for-each
|
||||
(fn ((ref :as string))
|
||||
(when (not (contains? all-refs ref))
|
||||
(append! all-refs ref)))
|
||||
(scan-io-refs (macro-body val) io-names))
|
||||
(for-each
|
||||
(fn ((dep :as string)) (transitive-io-refs-walk dep seen all-refs env io-names))
|
||||
(scan-refs (macro-body val))))
|
||||
|
||||
:else nil)))))
|
||||
|
||||
|
||||
(define transitive-io-refs :effects []
|
||||
(fn ((name :as string) (env :as dict) (io-names :as list))
|
||||
(let ((all-refs (list))
|
||||
(seen (list))
|
||||
(key (if (starts-with? name "~") name (str "~" name))))
|
||||
(transitive-io-refs-walk key seen all-refs env io-names)
|
||||
all-refs)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 10. Compute IO refs for all components in an environment
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define compute-all-io-refs :effects [mutation]
|
||||
(fn ((env :as dict) (io-names :as list))
|
||||
(for-each
|
||||
(fn ((name :as string))
|
||||
(let ((val (env-get env name)))
|
||||
(when (= (type-of val) "component")
|
||||
(component-set-io-refs! val (transitive-io-refs name env io-names)))))
|
||||
(env-components env))))
|
||||
|
||||
|
||||
(define component-io-refs-cached :effects []
|
||||
(fn ((name :as string) (env :as dict) (io-names :as list))
|
||||
(let ((key (if (starts-with? name "~") name (str "~" name))))
|
||||
(let ((val (env-get env key)))
|
||||
(if (and (= (type-of val) "component")
|
||||
(not (nil? (component-io-refs val)))
|
||||
(not (empty? (component-io-refs val))))
|
||||
(component-io-refs val)
|
||||
;; Fallback: not yet cached (shouldn't happen after compute-all-io-refs)
|
||||
(transitive-io-refs name env io-names))))))
|
||||
|
||||
(define component-pure? :effects []
|
||||
(fn ((name :as string) (env :as dict) (io-names :as list))
|
||||
(let ((key (if (starts-with? name "~") name (str "~" name))))
|
||||
(let ((val (env-get env key)))
|
||||
(if (and (= (type-of val) "component")
|
||||
(not (nil? (component-io-refs val))))
|
||||
;; Use cached io-refs (empty list = pure)
|
||||
(empty? (component-io-refs val))
|
||||
;; Fallback
|
||||
(empty? (transitive-io-refs name env io-names)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Render target — boundary decision per component
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Combines IO analysis with affinity annotations to decide where a
|
||||
;; component should render:
|
||||
;;
|
||||
;; :affinity :server → always "server" (auth-sensitive, secrets)
|
||||
;; :affinity :client → "client" even if IO-dependent (IO proxy)
|
||||
;; :affinity :auto → "server" if IO-dependent, "client" if pure
|
||||
;;
|
||||
;; Returns: "server" | "client"
|
||||
|
||||
(define render-target :effects []
|
||||
(fn ((name :as string) (env :as dict) (io-names :as list))
|
||||
(let ((key (if (starts-with? name "~") name (str "~" name))))
|
||||
(let ((val (env-get env key)))
|
||||
(if (not (= (type-of val) "component"))
|
||||
"server"
|
||||
(let ((affinity (component-affinity val)))
|
||||
(cond
|
||||
(= affinity "server") "server"
|
||||
(= affinity "client") "client"
|
||||
;; auto: decide from IO analysis
|
||||
(not (component-pure? name env io-names)) "server"
|
||||
:else "client")))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Page render plan — pre-computed boundary decisions for a page
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Given page source + env + IO names, returns a render plan dict:
|
||||
;;
|
||||
;; {:components {~name "server"|"client" ...}
|
||||
;; :server (list of ~names that render server-side)
|
||||
;; :client (list of ~names that render client-side)
|
||||
;; :io-deps (list of IO primitives needed by server components)}
|
||||
;;
|
||||
;; This is computed once at page registration and cached on the page def.
|
||||
;; The async evaluator and client router both use it to make decisions
|
||||
;; without recomputing at every request.
|
||||
|
||||
(define page-render-plan :effects []
|
||||
(fn ((page-source :as string) (env :as dict) (io-names :as list))
|
||||
(let ((needed (components-needed page-source env))
|
||||
(comp-targets (dict))
|
||||
(server-list (list))
|
||||
(client-list (list))
|
||||
(io-deps (list)))
|
||||
|
||||
(for-each
|
||||
(fn ((name :as string))
|
||||
(let ((target (render-target name env io-names)))
|
||||
(dict-set! comp-targets name target)
|
||||
(if (= target "server")
|
||||
(do
|
||||
(append! server-list name)
|
||||
;; Collect IO deps from server components (use cache)
|
||||
(for-each
|
||||
(fn ((io-ref :as string))
|
||||
(when (not (contains? io-deps io-ref))
|
||||
(append! io-deps io-ref)))
|
||||
(component-io-refs-cached name env io-names)))
|
||||
(append! client-list name))))
|
||||
needed)
|
||||
|
||||
{:components comp-targets
|
||||
:server server-list
|
||||
:client client-list
|
||||
:io-deps io-deps})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Host obligation: selective expansion in async partial evaluation
|
||||
;; --------------------------------------------------------------------------
|
||||
;; The spec classifies components as pure or IO-dependent and provides
|
||||
;; per-component render-target decisions. Each host's async partial
|
||||
;; evaluator (the server-side rendering path that bridges sync evaluation
|
||||
;; with async IO) must use this classification:
|
||||
;;
|
||||
;; render-target "server" → expand server-side (IO must resolve)
|
||||
;; render-target "client" → serialize for client (can render anywhere)
|
||||
;; Layout slot context → expand all (server needs full HTML)
|
||||
;;
|
||||
;; The spec provides: component-io-refs, component-pure?, render-target,
|
||||
;; component-affinity. The host provides the async runtime that acts on it.
|
||||
;; This is not SX semantics — it is host infrastructure. Every host
|
||||
;; with a server-side async evaluator implements the same rule.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface summary
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; From eval.sx (already provided):
|
||||
;; (type-of x) → type string
|
||||
;; (symbol-name s) → string name of symbol
|
||||
;; (env-get env k) → value or nil
|
||||
;;
|
||||
;; New for deps.sx (each host implements):
|
||||
;; (component-body c) → AST body of component
|
||||
;; (component-name c) → name string
|
||||
;; (component-deps c) → cached deps list (may be empty)
|
||||
;; (component-set-deps! c d)→ cache deps on component
|
||||
;; (component-css-classes c)→ pre-scanned CSS class list
|
||||
;; (component-io-refs c) → cached IO ref list (may be empty)
|
||||
;; (component-set-io-refs! c r)→ cache IO refs on component
|
||||
;; (component-affinity c) → "auto" | "client" | "server"
|
||||
;; (macro-body m) → AST body of macro
|
||||
;; (regex-find-all pat src) → list of capture group matches
|
||||
;; (scan-css-classes src) → list of CSS class strings from source
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; env-components — list component/macro names in an environment
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Moved from platform to spec: pure logic using type predicates.
|
||||
|
||||
(define env-components :effects []
|
||||
(fn ((env :as dict))
|
||||
(filter
|
||||
(fn ((k :as string))
|
||||
(let ((v (env-get env k)))
|
||||
(or (component? v) (macro? v))))
|
||||
(keys env))))
|
||||
803
web/engine.sx
Normal file
803
web/engine.sx
Normal file
@@ -0,0 +1,803 @@
|
||||
;; ==========================================================================
|
||||
;; engine.sx — SxEngine pure logic
|
||||
;;
|
||||
;; Fetch/swap/history engine for browser-side SX. Like HTMX but native
|
||||
;; to the SX rendering pipeline.
|
||||
;;
|
||||
;; This file specifies the pure LOGIC of the engine in s-expressions:
|
||||
;; parsing trigger specs, morph algorithm, swap dispatch, header building,
|
||||
;; retry logic, target resolution, etc.
|
||||
;;
|
||||
;; Orchestration (binding events, executing requests, processing elements)
|
||||
;; lives in orchestration.sx, which depends on this file.
|
||||
;;
|
||||
;; Depends on:
|
||||
;; adapter-dom.sx — render-to-dom (for SX response rendering)
|
||||
;; render.sx — shared registries
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Constants
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define ENGINE_VERBS (list "get" "post" "put" "delete" "patch"))
|
||||
(define DEFAULT_SWAP "outerHTML")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Trigger parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Parses the sx-trigger attribute value into a list of trigger descriptors.
|
||||
;; Each descriptor is a dict with "event" and "modifiers" keys.
|
||||
|
||||
(define parse-time :effects []
|
||||
(fn ((s :as string))
|
||||
;; Parse time string: "2s" → 2000, "500ms" → 500
|
||||
;; Uses nested if (not cond) because cond misclassifies 2-element
|
||||
;; function calls like (nil? s) as scheme-style ((test body)) clauses.
|
||||
(if (nil? s) 0
|
||||
(if (ends-with? s "ms") (parse-int s 0)
|
||||
(if (ends-with? s "s") (* (parse-int (replace s "s" "") 0) 1000)
|
||||
(parse-int s 0))))))
|
||||
|
||||
|
||||
(define parse-trigger-spec :effects []
|
||||
(fn ((spec :as string))
|
||||
;; Parse "click delay:500ms once,change" → list of trigger descriptors
|
||||
(if (nil? spec)
|
||||
nil
|
||||
(let ((raw-parts (split spec ",")))
|
||||
(filter
|
||||
(fn (x) (not (nil? x)))
|
||||
(map
|
||||
(fn ((part :as string))
|
||||
(let ((tokens (split (trim part) " ")))
|
||||
(if (empty? tokens)
|
||||
nil
|
||||
(if (and (= (first tokens) "every") (>= (len tokens) 2))
|
||||
;; Polling trigger
|
||||
(dict
|
||||
"event" "every"
|
||||
"modifiers" (dict "interval" (parse-time (nth tokens 1))))
|
||||
;; Normal trigger with optional modifiers
|
||||
(let ((mods (dict)))
|
||||
(for-each
|
||||
(fn ((tok :as string))
|
||||
(cond
|
||||
(= tok "once")
|
||||
(dict-set! mods "once" true)
|
||||
(= tok "changed")
|
||||
(dict-set! mods "changed" true)
|
||||
(starts-with? tok "delay:")
|
||||
(dict-set! mods "delay"
|
||||
(parse-time (slice tok 6)))
|
||||
(starts-with? tok "from:")
|
||||
(dict-set! mods "from"
|
||||
(slice tok 5))))
|
||||
(rest tokens))
|
||||
(dict "event" (first tokens) "modifiers" mods))))))
|
||||
raw-parts))))))
|
||||
|
||||
|
||||
(define default-trigger :effects []
|
||||
(fn ((tag-name :as string))
|
||||
;; Default trigger for element type
|
||||
(cond
|
||||
(= tag-name "FORM")
|
||||
(list (dict "event" "submit" "modifiers" (dict)))
|
||||
(or (= tag-name "INPUT")
|
||||
(= tag-name "SELECT")
|
||||
(= tag-name "TEXTAREA"))
|
||||
(list (dict "event" "change" "modifiers" (dict)))
|
||||
:else
|
||||
(list (dict "event" "click" "modifiers" (dict))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Verb extraction
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define get-verb-info :effects [io]
|
||||
(fn (el)
|
||||
;; Check element for sx-get, sx-post, etc. Returns (dict "method" "url") or nil.
|
||||
(some
|
||||
(fn ((verb :as string))
|
||||
(let ((url (dom-get-attr el (str "sx-" verb))))
|
||||
(if url
|
||||
(dict "method" (upper verb) "url" url)
|
||||
nil)))
|
||||
ENGINE_VERBS)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Request header building
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define build-request-headers :effects [io]
|
||||
(fn (el (loaded-components :as list) (css-hash :as string))
|
||||
;; Build the SX request headers dict
|
||||
(let ((headers (dict
|
||||
"SX-Request" "true"
|
||||
"SX-Current-URL" (browser-location-href))))
|
||||
;; Target selector
|
||||
(let ((target-sel (dom-get-attr el "sx-target")))
|
||||
(when target-sel
|
||||
(dict-set! headers "SX-Target" target-sel)))
|
||||
|
||||
;; Loaded component names
|
||||
(when (not (empty? loaded-components))
|
||||
(dict-set! headers "SX-Components"
|
||||
(join "," loaded-components)))
|
||||
|
||||
;; CSS class hash
|
||||
(when css-hash
|
||||
(dict-set! headers "SX-Css" css-hash))
|
||||
|
||||
;; Extra headers from sx-headers attribute
|
||||
(let ((extra-h (dom-get-attr el "sx-headers")))
|
||||
(when extra-h
|
||||
(let ((parsed (parse-header-value extra-h)))
|
||||
(when parsed
|
||||
(for-each
|
||||
(fn ((key :as string)) (dict-set! headers key (str (get parsed key))))
|
||||
(keys parsed))))))
|
||||
|
||||
headers)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Response header processing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define process-response-headers :effects []
|
||||
(fn ((get-header :as lambda))
|
||||
;; Extract all SX response header directives into a dict.
|
||||
;; get-header is (fn (name) → string or nil).
|
||||
(dict
|
||||
"redirect" (get-header "SX-Redirect")
|
||||
"refresh" (get-header "SX-Refresh")
|
||||
"trigger" (get-header "SX-Trigger")
|
||||
"retarget" (get-header "SX-Retarget")
|
||||
"reswap" (get-header "SX-Reswap")
|
||||
"location" (get-header "SX-Location")
|
||||
"replace-url" (get-header "SX-Replace-Url")
|
||||
"css-hash" (get-header "SX-Css-Hash")
|
||||
"trigger-swap" (get-header "SX-Trigger-After-Swap")
|
||||
"trigger-settle" (get-header "SX-Trigger-After-Settle")
|
||||
"content-type" (get-header "Content-Type")
|
||||
"cache-invalidate" (get-header "SX-Cache-Invalidate")
|
||||
"cache-update" (get-header "SX-Cache-Update"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Swap specification parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse-swap-spec :effects []
|
||||
(fn ((raw-swap :as string) (global-transitions? :as boolean))
|
||||
;; Parse "innerHTML transition:true" → dict with style + transition flag
|
||||
(let ((parts (split (or raw-swap DEFAULT_SWAP) " "))
|
||||
(style (first parts))
|
||||
(use-transition global-transitions?))
|
||||
(for-each
|
||||
(fn ((p :as string))
|
||||
(cond
|
||||
(= p "transition:true") (set! use-transition true)
|
||||
(= p "transition:false") (set! use-transition false)))
|
||||
(rest parts))
|
||||
(dict "style" style "transition" use-transition))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Retry logic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse-retry-spec :effects []
|
||||
(fn ((retry-attr :as string))
|
||||
;; Parse "exponential:1000:30000" → spec dict or nil
|
||||
(if (nil? retry-attr)
|
||||
nil
|
||||
(let ((parts (split retry-attr ":")))
|
||||
(dict
|
||||
"strategy" (first parts)
|
||||
"start-ms" (parse-int (nth parts 1) 1000)
|
||||
"cap-ms" (parse-int (nth parts 2) 30000))))))
|
||||
|
||||
|
||||
(define next-retry-ms :effects []
|
||||
(fn ((current-ms :as number) (cap-ms :as number))
|
||||
;; Exponential backoff: double current, cap at max
|
||||
(min (* current-ms 2) cap-ms)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Form parameter filtering
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define filter-params :effects []
|
||||
(fn ((params-spec :as string) (all-params :as list))
|
||||
;; Filter form parameters by sx-params spec.
|
||||
;; all-params is a list of (key value) pairs.
|
||||
;; Returns filtered list of (key value) pairs.
|
||||
;; Uses nested if (not cond) — see parse-time comment.
|
||||
(if (nil? params-spec) all-params
|
||||
(if (= params-spec "none") (list)
|
||||
(if (= params-spec "*") all-params
|
||||
(if (starts-with? params-spec "not ")
|
||||
(let ((excluded (map trim (split (slice params-spec 4) ","))))
|
||||
(filter
|
||||
(fn ((p :as list)) (not (contains? excluded (first p))))
|
||||
all-params))
|
||||
(let ((allowed (map trim (split params-spec ","))))
|
||||
(filter
|
||||
(fn ((p :as list)) (contains? allowed (first p)))
|
||||
all-params))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Target resolution
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define resolve-target :effects [io]
|
||||
(fn (el)
|
||||
;; Resolve the swap target for an element
|
||||
(let ((sel (dom-get-attr el "sx-target")))
|
||||
(cond
|
||||
(or (nil? sel) (= sel "this")) el
|
||||
(= sel "closest") (dom-parent el)
|
||||
:else (dom-query sel)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Optimistic updates
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define apply-optimistic :effects [mutation io]
|
||||
(fn (el)
|
||||
;; Apply optimistic update preview. Returns state for reverting, or nil.
|
||||
(let ((directive (dom-get-attr el "sx-optimistic")))
|
||||
(if (nil? directive)
|
||||
nil
|
||||
(let ((target (or (resolve-target el) el))
|
||||
(state (dict "target" target "directive" directive)))
|
||||
(cond
|
||||
(= directive "remove")
|
||||
(do
|
||||
(dict-set! state "opacity" (dom-get-style target "opacity"))
|
||||
(dom-set-style target "opacity" "0")
|
||||
(dom-set-style target "pointer-events" "none"))
|
||||
(= directive "disable")
|
||||
(do
|
||||
(dict-set! state "disabled" (dom-get-prop target "disabled"))
|
||||
(dom-set-prop target "disabled" true))
|
||||
(starts-with? directive "add-class:")
|
||||
(let ((cls (slice directive 10)))
|
||||
(dict-set! state "add-class" cls)
|
||||
(dom-add-class target cls)))
|
||||
state)))))
|
||||
|
||||
|
||||
(define revert-optimistic :effects [mutation io]
|
||||
(fn ((state :as dict))
|
||||
;; Revert an optimistic update
|
||||
(when state
|
||||
(let ((target (get state "target"))
|
||||
(directive (get state "directive")))
|
||||
(cond
|
||||
(= directive "remove")
|
||||
(do
|
||||
(dom-set-style target "opacity" (or (get state "opacity") ""))
|
||||
(dom-set-style target "pointer-events" ""))
|
||||
(= directive "disable")
|
||||
(dom-set-prop target "disabled" (or (get state "disabled") false))
|
||||
(get state "add-class")
|
||||
(dom-remove-class target (get state "add-class")))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Out-of-band swap identification
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define find-oob-swaps :effects [mutation io]
|
||||
(fn (container)
|
||||
;; Find elements marked for out-of-band swapping.
|
||||
;; Returns list of (dict "element" el "swap-type" type "target-id" id).
|
||||
(let ((results (list)))
|
||||
(for-each
|
||||
(fn ((attr :as string))
|
||||
(let ((oob-els (dom-query-all container (str "[" attr "]"))))
|
||||
(for-each
|
||||
(fn (oob)
|
||||
(let ((swap-type (or (dom-get-attr oob attr) "outerHTML"))
|
||||
(target-id (dom-id oob)))
|
||||
(dom-remove-attr oob attr)
|
||||
(when target-id
|
||||
(append! results
|
||||
(dict "element" oob
|
||||
"swap-type" swap-type
|
||||
"target-id" target-id)))))
|
||||
oob-els)))
|
||||
(list "sx-swap-oob" "hx-swap-oob"))
|
||||
results)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; DOM morph algorithm
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Lightweight reconciler: patches oldNode to match newNode in-place,
|
||||
;; preserving event listeners, focus, scroll position, and form state
|
||||
;; on keyed (id) elements.
|
||||
|
||||
(define morph-node :effects [mutation io]
|
||||
(fn (old-node new-node)
|
||||
;; Morph old-node to match new-node, preserving listeners/state.
|
||||
(cond
|
||||
;; sx-preserve / sx-ignore → skip
|
||||
(or (dom-has-attr? old-node "sx-preserve")
|
||||
(dom-has-attr? old-node "sx-ignore"))
|
||||
nil
|
||||
|
||||
;; Hydrated island → preserve reactive state, morph lakes.
|
||||
;; If old and new are the same island (by name), keep the old DOM
|
||||
;; with its live signals, effects, and event listeners intact.
|
||||
;; But recurse into data-sx-lake slots so the server can update
|
||||
;; non-reactive content within the island.
|
||||
(and (dom-has-attr? old-node "data-sx-island")
|
||||
(is-processed? old-node "island-hydrated")
|
||||
(dom-has-attr? new-node "data-sx-island")
|
||||
(= (dom-get-attr old-node "data-sx-island")
|
||||
(dom-get-attr new-node "data-sx-island")))
|
||||
(morph-island-children old-node new-node)
|
||||
|
||||
;; Different node type or tag → replace wholesale
|
||||
(or (not (= (dom-node-type old-node) (dom-node-type new-node)))
|
||||
(not (= (dom-node-name old-node) (dom-node-name new-node))))
|
||||
(dom-replace-child (dom-parent old-node)
|
||||
(dom-clone new-node) old-node)
|
||||
|
||||
;; Text/comment nodes → update content
|
||||
(or (= (dom-node-type old-node) 3) (= (dom-node-type old-node) 8))
|
||||
(when (not (= (dom-text-content old-node) (dom-text-content new-node)))
|
||||
(dom-set-text-content old-node (dom-text-content new-node)))
|
||||
|
||||
;; Element nodes → sync attributes, then recurse children
|
||||
(= (dom-node-type old-node) 1)
|
||||
(do
|
||||
(sync-attrs old-node new-node)
|
||||
;; Skip morphing focused input to preserve user's in-progress edits
|
||||
(when (not (and (dom-is-active-element? old-node)
|
||||
(dom-is-input-element? old-node)))
|
||||
(morph-children old-node new-node))))))
|
||||
|
||||
|
||||
(define sync-attrs :effects [mutation io]
|
||||
(fn (old-el new-el)
|
||||
;; Sync attributes from new to old, but skip reactively managed attrs.
|
||||
;; data-sx-reactive-attrs="style,class" means those attrs are owned by
|
||||
;; signal effects and must not be overwritten by the morph.
|
||||
(let ((ra-str (or (dom-get-attr old-el "data-sx-reactive-attrs") ""))
|
||||
(reactive-attrs (if (empty? ra-str) (list) (split ra-str ","))))
|
||||
;; Add/update attributes from new, skip reactive ones
|
||||
(for-each
|
||||
(fn ((attr :as list))
|
||||
(let ((name (first attr))
|
||||
(val (nth attr 1)))
|
||||
(when (and (not (= (dom-get-attr old-el name) val))
|
||||
(not (contains? reactive-attrs name)))
|
||||
(dom-set-attr old-el name val))))
|
||||
(dom-attr-list new-el))
|
||||
;; Remove attributes not in new, skip reactive + marker attrs
|
||||
(for-each
|
||||
(fn ((attr :as list))
|
||||
(let ((aname (first attr)))
|
||||
(when (and (not (dom-has-attr? new-el aname))
|
||||
(not (contains? reactive-attrs aname))
|
||||
(not (= aname "data-sx-reactive-attrs")))
|
||||
(dom-remove-attr old-el aname))))
|
||||
(dom-attr-list old-el)))))
|
||||
|
||||
|
||||
(define morph-children :effects [mutation io]
|
||||
(fn (old-parent new-parent)
|
||||
;; Reconcile children of old-parent to match new-parent.
|
||||
;; Keyed elements (with id) are matched and moved in-place.
|
||||
(let ((old-kids (dom-child-list old-parent))
|
||||
(new-kids (dom-child-list new-parent))
|
||||
;; Build ID map of old children for keyed matching
|
||||
(old-by-id (reduce
|
||||
(fn ((acc :as dict) kid)
|
||||
(let ((id (dom-id kid)))
|
||||
(if id (do (dict-set! acc id kid) acc) acc)))
|
||||
(dict) old-kids))
|
||||
(oi 0))
|
||||
|
||||
;; Walk new children, morph/insert/append
|
||||
(for-each
|
||||
(fn (new-child)
|
||||
(let ((match-id (dom-id new-child))
|
||||
(match-by-id (if match-id (dict-get old-by-id match-id) nil)))
|
||||
(cond
|
||||
;; Keyed match — move into position if needed, then morph
|
||||
(and match-by-id (not (nil? match-by-id)))
|
||||
(do
|
||||
(when (and (< oi (len old-kids))
|
||||
(not (= match-by-id (nth old-kids oi))))
|
||||
(dom-insert-before old-parent match-by-id
|
||||
(if (< oi (len old-kids)) (nth old-kids oi) nil)))
|
||||
(morph-node match-by-id new-child)
|
||||
(set! oi (inc oi)))
|
||||
|
||||
;; Positional match
|
||||
(< oi (len old-kids))
|
||||
(let ((old-child (nth old-kids oi)))
|
||||
(if (and (dom-id old-child) (not match-id))
|
||||
;; Old has ID, new doesn't — insert new before old
|
||||
(dom-insert-before old-parent
|
||||
(dom-clone new-child) old-child)
|
||||
;; Normal positional morph
|
||||
(do
|
||||
(morph-node old-child new-child)
|
||||
(set! oi (inc oi)))))
|
||||
|
||||
;; Extra new children — append
|
||||
:else
|
||||
(dom-append old-parent (dom-clone new-child)))))
|
||||
new-kids)
|
||||
|
||||
;; Remove leftover old children
|
||||
(for-each
|
||||
(fn ((i :as number))
|
||||
(when (>= i oi)
|
||||
(let ((leftover (nth old-kids i)))
|
||||
(when (and (dom-is-child-of? leftover old-parent)
|
||||
(not (dom-has-attr? leftover "sx-preserve"))
|
||||
(not (dom-has-attr? leftover "sx-ignore")))
|
||||
(dom-remove-child old-parent leftover)))))
|
||||
(range oi (len old-kids))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; morph-island-children — deep morph into hydrated islands via lakes
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Level 2-3 island morphing: the server can update non-reactive content
|
||||
;; within hydrated islands by morphing data-sx-lake slots.
|
||||
;;
|
||||
;; The island's reactive DOM (signals, effects, event listeners) is preserved.
|
||||
;; Only lake slots — explicitly marked server territory — receive new content.
|
||||
;;
|
||||
;; This is the Hegelian synthesis made concrete:
|
||||
;; - Islands = client subjectivity (reactive state, preserved)
|
||||
;; - Lakes = server substance (content, morphed)
|
||||
;; - The morph = Aufhebung (cancellation/preservation/elevation of both)
|
||||
|
||||
(define morph-island-children :effects [mutation io]
|
||||
(fn (old-island new-island)
|
||||
;; Find all lake and marsh slots in both old and new islands
|
||||
(let ((old-lakes (dom-query-all old-island "[data-sx-lake]"))
|
||||
(new-lakes (dom-query-all new-island "[data-sx-lake]"))
|
||||
(old-marshes (dom-query-all old-island "[data-sx-marsh]"))
|
||||
(new-marshes (dom-query-all new-island "[data-sx-marsh]")))
|
||||
;; Build ID→element maps for new lakes and marshes
|
||||
(let ((new-lake-map (dict))
|
||||
(new-marsh-map (dict)))
|
||||
(for-each
|
||||
(fn (lake)
|
||||
(let ((id (dom-get-attr lake "data-sx-lake")))
|
||||
(when id (dict-set! new-lake-map id lake))))
|
||||
new-lakes)
|
||||
(for-each
|
||||
(fn (marsh)
|
||||
(let ((id (dom-get-attr marsh "data-sx-marsh")))
|
||||
(when id (dict-set! new-marsh-map id marsh))))
|
||||
new-marshes)
|
||||
;; Morph each old lake from its new counterpart
|
||||
(for-each
|
||||
(fn (old-lake)
|
||||
(let ((id (dom-get-attr old-lake "data-sx-lake")))
|
||||
(let ((new-lake (dict-get new-lake-map id)))
|
||||
(when new-lake
|
||||
(sync-attrs old-lake new-lake)
|
||||
(morph-children old-lake new-lake)))))
|
||||
old-lakes)
|
||||
;; Morph each old marsh from its new counterpart
|
||||
(for-each
|
||||
(fn (old-marsh)
|
||||
(let ((id (dom-get-attr old-marsh "data-sx-marsh")))
|
||||
(let ((new-marsh (dict-get new-marsh-map id)))
|
||||
(when new-marsh
|
||||
(morph-marsh old-marsh new-marsh old-island)))))
|
||||
old-marshes)
|
||||
;; Process data-sx-signal attributes — server writes to named stores
|
||||
(process-signal-updates new-island)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; morph-marsh — re-evaluate server content in island's reactive scope
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Marshes are zones inside islands where server content is re-evaluated by
|
||||
;; the island's reactive evaluator. During morph, the new content is parsed
|
||||
;; as SX and rendered in the island's signal context. If the marsh has a
|
||||
;; :transform function, it reshapes the content before evaluation.
|
||||
|
||||
(define morph-marsh :effects [mutation io]
|
||||
(fn (old-marsh new-marsh island-el)
|
||||
(let ((transform (dom-get-data old-marsh "sx-marsh-transform"))
|
||||
(env (dom-get-data old-marsh "sx-marsh-env"))
|
||||
(new-html (dom-inner-html new-marsh)))
|
||||
(if (and env new-html (not (empty? new-html)))
|
||||
;; Parse new content as SX and re-evaluate in island scope
|
||||
(let ((parsed (parse new-html)))
|
||||
(let ((sx-content (if transform (cek-call transform (list parsed)) parsed)))
|
||||
;; Dispose old reactive bindings in this marsh
|
||||
(dispose-marsh-scope old-marsh)
|
||||
;; Evaluate the SX in a new marsh scope — creates new reactive bindings
|
||||
(with-marsh-scope old-marsh
|
||||
(fn ()
|
||||
(let ((new-dom (render-to-dom sx-content env nil)))
|
||||
;; Replace marsh children
|
||||
(dom-remove-children-after old-marsh nil)
|
||||
(dom-append old-marsh new-dom))))))
|
||||
;; Fallback: morph like a lake
|
||||
(do
|
||||
(sync-attrs old-marsh new-marsh)
|
||||
(morph-children old-marsh new-marsh))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; process-signal-updates — server responses write to named store signals
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Elements with data-sx-signal="name:value" trigger signal writes.
|
||||
;; After processing, the attribute is removed (consumed).
|
||||
;;
|
||||
;; Values are JSON-parsed: "7" → 7, "\"hello\"" → "hello", "true" → true.
|
||||
|
||||
(define process-signal-updates :effects [mutation io]
|
||||
(fn (root)
|
||||
(let ((signal-els (dom-query-all root "[data-sx-signal]")))
|
||||
(for-each
|
||||
(fn (el)
|
||||
(let ((spec (dom-get-attr el "data-sx-signal")))
|
||||
(when spec
|
||||
(let ((colon-idx (index-of spec ":")))
|
||||
(when (> colon-idx 0)
|
||||
(let ((store-name (slice spec 0 colon-idx))
|
||||
(raw-value (slice spec (+ colon-idx 1))))
|
||||
(let ((parsed (json-parse raw-value)))
|
||||
(reset! (use-store store-name) parsed))
|
||||
(dom-remove-attr el "data-sx-signal")))))))
|
||||
signal-els))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Swap dispatch
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define swap-dom-nodes :effects [mutation io]
|
||||
(fn (target new-nodes (strategy :as string))
|
||||
;; Execute a swap strategy on live DOM nodes.
|
||||
;; new-nodes is typically a DocumentFragment or Element.
|
||||
(case strategy
|
||||
"innerHTML"
|
||||
(if (dom-is-fragment? new-nodes)
|
||||
(morph-children target new-nodes)
|
||||
(let ((wrapper (dom-create-element "div" nil)))
|
||||
(dom-append wrapper new-nodes)
|
||||
(morph-children target wrapper)))
|
||||
|
||||
"outerHTML"
|
||||
(let ((parent (dom-parent target)))
|
||||
(if (dom-is-fragment? new-nodes)
|
||||
;; Fragment — morph first child, insert rest
|
||||
(let ((fc (dom-first-child new-nodes)))
|
||||
(if fc
|
||||
(do
|
||||
(morph-node target fc)
|
||||
;; Insert remaining siblings after morphed element
|
||||
(let ((sib (dom-next-sibling fc)))
|
||||
(insert-remaining-siblings parent target sib)))
|
||||
(dom-remove-child parent target)))
|
||||
(morph-node target new-nodes))
|
||||
parent)
|
||||
|
||||
"afterend"
|
||||
(dom-insert-after target new-nodes)
|
||||
|
||||
"beforeend"
|
||||
(dom-append target new-nodes)
|
||||
|
||||
"afterbegin"
|
||||
(dom-prepend target new-nodes)
|
||||
|
||||
"beforebegin"
|
||||
(dom-insert-before (dom-parent target) new-nodes target)
|
||||
|
||||
"delete"
|
||||
(dom-remove-child (dom-parent target) target)
|
||||
|
||||
"none"
|
||||
nil
|
||||
|
||||
;; Default = innerHTML
|
||||
:else
|
||||
(if (dom-is-fragment? new-nodes)
|
||||
(morph-children target new-nodes)
|
||||
(let ((wrapper (dom-create-element "div" nil)))
|
||||
(dom-append wrapper new-nodes)
|
||||
(morph-children target wrapper))))))
|
||||
|
||||
|
||||
(define insert-remaining-siblings :effects [mutation io]
|
||||
(fn (parent ref-node sib)
|
||||
;; Insert sibling chain after ref-node
|
||||
(when sib
|
||||
(let ((next (dom-next-sibling sib)))
|
||||
(dom-insert-after ref-node sib)
|
||||
(insert-remaining-siblings parent sib next)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; String-based swap (fallback for HTML responses)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define swap-html-string :effects [mutation io]
|
||||
(fn (target (html :as string) (strategy :as string))
|
||||
;; Execute a swap strategy using an HTML string (DOMParser pipeline).
|
||||
(case strategy
|
||||
"innerHTML"
|
||||
(dom-set-inner-html target html)
|
||||
"outerHTML"
|
||||
(let ((parent (dom-parent target)))
|
||||
(dom-insert-adjacent-html target "afterend" html)
|
||||
(dom-remove-child parent target)
|
||||
parent)
|
||||
"afterend"
|
||||
(dom-insert-adjacent-html target "afterend" html)
|
||||
"beforeend"
|
||||
(dom-insert-adjacent-html target "beforeend" html)
|
||||
"afterbegin"
|
||||
(dom-insert-adjacent-html target "afterbegin" html)
|
||||
"beforebegin"
|
||||
(dom-insert-adjacent-html target "beforebegin" html)
|
||||
"delete"
|
||||
(dom-remove-child (dom-parent target) target)
|
||||
"none"
|
||||
nil
|
||||
:else
|
||||
(dom-set-inner-html target html))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; History management
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define handle-history :effects [io]
|
||||
(fn (el (url :as string) (resp-headers :as dict))
|
||||
;; Process history push/replace based on element attrs and response headers
|
||||
(let ((push-url (dom-get-attr el "sx-push-url"))
|
||||
(replace-url (dom-get-attr el "sx-replace-url"))
|
||||
(hdr-replace (get resp-headers "replace-url")))
|
||||
(cond
|
||||
;; Server override
|
||||
hdr-replace
|
||||
(browser-replace-state hdr-replace)
|
||||
;; Client push
|
||||
(and push-url (not (= push-url "false")))
|
||||
(browser-push-state
|
||||
(if (= push-url "true") url push-url))
|
||||
;; Client replace
|
||||
(and replace-url (not (= replace-url "false")))
|
||||
(browser-replace-state
|
||||
(if (= replace-url "true") url replace-url))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Preload cache
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define PRELOAD_TTL 30000) ;; 30 seconds
|
||||
|
||||
(define preload-cache-get :effects [mutation]
|
||||
(fn ((cache :as dict) (url :as string))
|
||||
;; Get and consume a cached preload response.
|
||||
;; Returns (dict "text" ... "content-type" ...) or nil.
|
||||
(let ((entry (dict-get cache url)))
|
||||
(if (nil? entry)
|
||||
nil
|
||||
(if (> (- (now-ms) (get entry "timestamp")) PRELOAD_TTL)
|
||||
(do (dict-delete! cache url) nil)
|
||||
(do (dict-delete! cache url) entry))))))
|
||||
|
||||
|
||||
(define preload-cache-set :effects [mutation]
|
||||
(fn ((cache :as dict) (url :as string) (text :as string) (content-type :as string))
|
||||
;; Store a preloaded response
|
||||
(dict-set! cache url
|
||||
(dict "text" text "content-type" content-type "timestamp" (now-ms)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Trigger dispatch table
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Maps trigger event names to binding strategies.
|
||||
;; This is the logic; actual browser event binding is platform interface.
|
||||
|
||||
(define classify-trigger :effects []
|
||||
(fn ((trigger :as dict))
|
||||
;; Classify a parsed trigger descriptor for binding.
|
||||
;; Returns one of: "poll", "intersect", "load", "revealed", "event"
|
||||
(let ((event (get trigger "event")))
|
||||
(cond
|
||||
(= event "every") "poll"
|
||||
(= event "intersect") "intersect"
|
||||
(= event "load") "load"
|
||||
(= event "revealed") "revealed"
|
||||
:else "event"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Boost logic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define should-boost-link? :effects [io]
|
||||
(fn (link)
|
||||
;; Whether a link inside an sx-boost container should be boosted
|
||||
(let ((href (dom-get-attr link "href")))
|
||||
(and href
|
||||
(not (starts-with? href "#"))
|
||||
(not (starts-with? href "javascript:"))
|
||||
(not (starts-with? href "mailto:"))
|
||||
(browser-same-origin? href)
|
||||
(not (dom-has-attr? link "sx-get"))
|
||||
(not (dom-has-attr? link "sx-post"))
|
||||
(not (dom-has-attr? link "sx-disable"))))))
|
||||
|
||||
|
||||
(define should-boost-form? :effects [io]
|
||||
(fn (form)
|
||||
;; Whether a form inside an sx-boost container should be boosted
|
||||
(and (not (dom-has-attr? form "sx-get"))
|
||||
(not (dom-has-attr? form "sx-post"))
|
||||
(not (dom-has-attr? form "sx-disable")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; SSE event classification
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse-sse-swap :effects [io]
|
||||
(fn (el)
|
||||
;; Parse sx-sse-swap attribute
|
||||
;; Returns event name to listen for (default "message")
|
||||
(or (dom-get-attr el "sx-sse-swap") "message")))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — Engine (pure logic)
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; From adapter-dom.sx:
|
||||
;; dom-get-attr, dom-set-attr, dom-remove-attr, dom-has-attr?, dom-attr-list
|
||||
;; dom-query, dom-query-all, dom-id, dom-parent, dom-first-child,
|
||||
;; dom-next-sibling, dom-child-list, dom-node-type, dom-node-name,
|
||||
;; dom-text-content, dom-set-text-content, dom-is-fragment?,
|
||||
;; dom-is-child-of?, dom-is-active-element?, dom-is-input-element?,
|
||||
;; dom-create-element, dom-append, dom-prepend, dom-insert-before,
|
||||
;; dom-insert-after, dom-remove-child, dom-replace-child, dom-clone,
|
||||
;; dom-get-style, dom-set-style, dom-get-prop, dom-set-prop,
|
||||
;; dom-add-class, dom-remove-class, dom-set-inner-html,
|
||||
;; dom-insert-adjacent-html
|
||||
;;
|
||||
;; Browser/Network:
|
||||
;; (browser-location-href) → current URL string
|
||||
;; (browser-same-origin? url) → boolean
|
||||
;; (browser-push-state url) → void (history.pushState)
|
||||
;; (browser-replace-state url) → void (history.replaceState)
|
||||
;;
|
||||
;; Parsing:
|
||||
;; (parse-header-value s) → parsed dict from header string
|
||||
;; (now-ms) → current timestamp in milliseconds
|
||||
;; --------------------------------------------------------------------------
|
||||
278
web/forms.sx
Normal file
278
web/forms.sx
Normal file
@@ -0,0 +1,278 @@
|
||||
;; ==========================================================================
|
||||
;; forms.sx — Server-side definition forms
|
||||
;;
|
||||
;; Platform-specific special forms for declaring handlers, pages, queries,
|
||||
;; and actions. These parse &key parameter lists and create typed definition
|
||||
;; objects that the server runtime uses for routing and execution.
|
||||
;;
|
||||
;; When SX moves to isomorphic execution, these forms will have different
|
||||
;; platform bindings on client vs server. The spec stays the same — only
|
||||
;; the constructors (make-handler-def, make-query-def, etc.) change.
|
||||
;;
|
||||
;; Platform functions required:
|
||||
;; make-handler-def(name, params, body, env) → HandlerDef
|
||||
;; make-query-def(name, params, doc, body, env) → QueryDef
|
||||
;; make-action-def(name, params, doc, body, env) → ActionDef
|
||||
;; make-page-def(name, slots, env) → PageDef
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Shared: parse (&key param1 param2 ...) → list of param name strings
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse-key-params
|
||||
(fn ((params-expr :as list))
|
||||
(let ((params (list))
|
||||
(in-key false))
|
||||
(for-each
|
||||
(fn (p)
|
||||
(when (= (type-of p) "symbol")
|
||||
(let ((name (symbol-name p)))
|
||||
(cond
|
||||
(= name "&key") (set! in-key true)
|
||||
in-key (append! params name)
|
||||
:else (append! params name)))))
|
||||
params-expr)
|
||||
params)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defhandler — (defhandler name [:path "..." :method :get :csrf false :returns "element"] (&key param...) body)
|
||||
;;
|
||||
;; Keyword options between name and params list:
|
||||
;; :path — public route path (string). Without :path, handler is internal-only.
|
||||
;; :method — HTTP method (keyword: :get :post :put :patch :delete). Default :get.
|
||||
;; :csrf — CSRF protection (boolean). Default true; set false for POST/PUT etc.
|
||||
;; :returns — return type annotation (types.sx vocabulary). Default "element".
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse-handler-args
|
||||
(fn ((args :as list))
|
||||
"Parse defhandler args after the name symbol.
|
||||
Scans for :keyword value option pairs, then a list (params), then body.
|
||||
Returns dict with keys: opts, params, body."
|
||||
(let ((opts {})
|
||||
(params (list))
|
||||
(body nil)
|
||||
(i 0)
|
||||
(n (len args))
|
||||
(done false))
|
||||
(for-each
|
||||
(fn (idx)
|
||||
(when (and (not done) (= idx i))
|
||||
(let ((arg (nth args idx)))
|
||||
(cond
|
||||
;; keyword-value pair → consume two items
|
||||
(= (type-of arg) "keyword")
|
||||
(do
|
||||
(when (< (+ idx 1) n)
|
||||
(let ((val (nth args (+ idx 1))))
|
||||
;; For :method, extract keyword name; for :csrf, keep as-is
|
||||
(dict-set! opts (keyword-name arg)
|
||||
(if (= (type-of val) "keyword")
|
||||
(keyword-name val)
|
||||
val))))
|
||||
(set! i (+ idx 2)))
|
||||
;; list → params, next element is body
|
||||
(= (type-of arg) "list")
|
||||
(do
|
||||
(set! params (parse-key-params arg))
|
||||
(when (< (+ idx 1) n)
|
||||
(set! body (nth args (+ idx 1))))
|
||||
(set! done true))
|
||||
;; anything else → no explicit params, this is body
|
||||
:else
|
||||
(do
|
||||
(set! body arg)
|
||||
(set! done true))))))
|
||||
(range 0 n))
|
||||
(dict :opts opts :params params :body body))))
|
||||
|
||||
(define sf-defhandler
|
||||
(fn ((args :as list) (env :as dict))
|
||||
(let ((name-sym (first args))
|
||||
(name (symbol-name name-sym))
|
||||
(parsed (parse-handler-args (rest args)))
|
||||
(opts (get parsed "opts"))
|
||||
(params (get parsed "params"))
|
||||
(body (get parsed "body")))
|
||||
(let ((hdef (make-handler-def name params body env opts)))
|
||||
(env-set! env (str "handler:" name) hdef)
|
||||
hdef))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defquery — (defquery name (&key param...) "docstring" body)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-defquery
|
||||
(fn ((args :as list) (env :as dict))
|
||||
(let ((name-sym (first args))
|
||||
(params-raw (nth args 1))
|
||||
(name (symbol-name name-sym))
|
||||
(params (parse-key-params params-raw))
|
||||
;; Optional docstring before body
|
||||
(has-doc (and (>= (len args) 4) (= (type-of (nth args 2)) "string")))
|
||||
(doc (if has-doc (nth args 2) ""))
|
||||
(body (if has-doc (nth args 3) (nth args 2))))
|
||||
(let ((qdef (make-query-def name params doc body env)))
|
||||
(env-set! env (str "query:" name) qdef)
|
||||
qdef))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defaction — (defaction name (&key param...) "docstring" body)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-defaction
|
||||
(fn ((args :as list) (env :as dict))
|
||||
(let ((name-sym (first args))
|
||||
(params-raw (nth args 1))
|
||||
(name (symbol-name name-sym))
|
||||
(params (parse-key-params params-raw))
|
||||
(has-doc (and (>= (len args) 4) (= (type-of (nth args 2)) "string")))
|
||||
(doc (if has-doc (nth args 2) ""))
|
||||
(body (if has-doc (nth args 3) (nth args 2))))
|
||||
(let ((adef (make-action-def name params doc body env)))
|
||||
(env-set! env (str "action:" name) adef)
|
||||
adef))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defpage — (defpage name :path "/..." :auth :public :content expr ...)
|
||||
;;
|
||||
;; Keyword-slot form: all values after the name are :key value pairs.
|
||||
;; Values are stored as unevaluated AST — resolved at request time.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-defpage
|
||||
(fn ((args :as list) (env :as dict))
|
||||
(let ((name-sym (first args))
|
||||
(name (symbol-name name-sym))
|
||||
(slots {}))
|
||||
;; Parse keyword slots from remaining args
|
||||
(let ((i 1)
|
||||
(max-i (len args)))
|
||||
(for-each
|
||||
(fn ((idx :as number))
|
||||
(when (and (< idx max-i)
|
||||
(= (type-of (nth args idx)) "keyword"))
|
||||
(when (< (+ idx 1) max-i)
|
||||
(dict-set! slots (keyword-name (nth args idx))
|
||||
(nth args (+ idx 1))))))
|
||||
(range 1 max-i 2)))
|
||||
(let ((pdef (make-page-def name slots env)))
|
||||
(env-set! env (str "page:" name) pdef)
|
||||
pdef))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; Page Execution Semantics
|
||||
;; ==========================================================================
|
||||
;;
|
||||
;; A PageDef describes what to render for a route. The host evaluates slots
|
||||
;; at request time. This section specifies the data → content protocol that
|
||||
;; every host must implement identically.
|
||||
;;
|
||||
;; Slots (all unevaluated AST):
|
||||
;; :path — route pattern (string)
|
||||
;; :auth — "public" | "login" | "admin"
|
||||
;; :layout — layout reference + kwargs
|
||||
;; :stream — boolean, opt into chunked transfer
|
||||
;; :shell — immediate content (contains ~suspense placeholders)
|
||||
;; :fallback — loading skeleton for single-stream mode
|
||||
;; :data — IO expression producing bindings
|
||||
;; :content — template expression evaluated with data bindings
|
||||
;; :filter, :aside, :menu — additional content slots
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Data Protocol
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; The :data expression is evaluated at request time. It returns one of:
|
||||
;;
|
||||
;; 1. A dict — single-stream mode (default).
|
||||
;; Each key becomes an env binding (underscores → hyphens).
|
||||
;; Then :content is evaluated once with those bindings.
|
||||
;; Result resolves the "stream-content" suspense slot.
|
||||
;;
|
||||
;; 2. A sequence of dicts — multi-stream mode.
|
||||
;; The host delivers items over time (async generator, channel, etc.).
|
||||
;; Each dict:
|
||||
;; - MUST contain "stream-id" → string matching a ~suspense :id
|
||||
;; - Remaining keys become env bindings (underscores → hyphens)
|
||||
;; - :content is re-evaluated with those bindings
|
||||
;; - Result resolves the ~suspense slot matching "stream-id"
|
||||
;; If "stream-id" is absent, defaults to "stream-content".
|
||||
;;
|
||||
;; The host is free to choose the timing mechanism:
|
||||
;; Python — async generator (yield dicts at intervals)
|
||||
;; Go — channel of dicts
|
||||
;; Haskell — conduit / streaming
|
||||
;; JS — async iterator
|
||||
;;
|
||||
;; The spec requires:
|
||||
;; (a) Each item's bindings are isolated (fresh env per item)
|
||||
;; (b) :content is evaluated independently for each item
|
||||
;; (c) Resolution is incremental — each item resolves as it arrives
|
||||
;; (d) "stream-id" routes to the correct ~suspense slot
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Streaming Execution Order
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; When :stream is true:
|
||||
;;
|
||||
;; 1. Evaluate :shell (if present) → HTML for immediate content slot
|
||||
;; :shell typically contains ~suspense placeholders with :fallback
|
||||
;; 2. Render HTML shell with suspense placeholders → send to client
|
||||
;; 3. Start :data evaluation concurrently with header resolution
|
||||
;; 4. As each data item arrives:
|
||||
;; a. Bind item keys into fresh env
|
||||
;; b. Evaluate :content with those bindings → SX wire format
|
||||
;; c. Send resolve script: __sxResolve(stream-id, sx)
|
||||
;; 5. Close response when all items + headers have resolved
|
||||
;;
|
||||
;; Non-streaming pages evaluate :data then :content sequentially and
|
||||
;; return the complete page in a single response.
|
||||
;;
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Spec helpers for multi-stream data protocol
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Extract stream-id from a data chunk dict, defaulting to "stream-content"
|
||||
(define stream-chunk-id
|
||||
(fn ((chunk :as dict))
|
||||
(if (has-key? chunk "stream-id")
|
||||
(get chunk "stream-id")
|
||||
"stream-content")))
|
||||
|
||||
;; Remove stream-id from chunk, returning only the bindings
|
||||
(define stream-chunk-bindings
|
||||
(fn ((chunk :as dict))
|
||||
(dissoc chunk "stream-id")))
|
||||
|
||||
;; Normalize binding keys: underscore → hyphen
|
||||
(define normalize-binding-key
|
||||
(fn ((key :as string))
|
||||
(replace key "_" "-")))
|
||||
|
||||
;; Bind a data chunk's keys into a fresh env (isolated per chunk)
|
||||
(define bind-stream-chunk
|
||||
(fn ((chunk :as dict) (base-env :as dict))
|
||||
(let ((env (merge {} base-env))
|
||||
(bindings (stream-chunk-bindings chunk)))
|
||||
(for-each
|
||||
(fn ((key :as string))
|
||||
(env-set! env (normalize-binding-key key)
|
||||
(get bindings key)))
|
||||
(keys bindings))
|
||||
env)))
|
||||
|
||||
;; Validate a multi-stream data result: must be a list of dicts
|
||||
(define validate-stream-data
|
||||
(fn (data)
|
||||
(and (= (type-of data) "list")
|
||||
(every? (fn (item) (= (type-of item) "dict")) data))))
|
||||
1414
web/orchestration.sx
Normal file
1414
web/orchestration.sx
Normal file
File diff suppressed because it is too large
Load Diff
368
web/page-helpers.sx
Normal file
368
web/page-helpers.sx
Normal file
@@ -0,0 +1,368 @@
|
||||
;; ==========================================================================
|
||||
;; page-helpers.sx — Pure data-transformation page helpers
|
||||
;;
|
||||
;; These functions take raw data (from Python I/O edge) and return
|
||||
;; structured dicts for page rendering. No I/O — pure transformations
|
||||
;; only. Bootstrapped to every host.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; categorize-special-forms
|
||||
;;
|
||||
;; Parses define-special-form declarations from special-forms.sx AST,
|
||||
;; categorizes each form by name lookup, returns dict of category → forms.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define special-form-category-map
|
||||
{"if" "Control Flow" "when" "Control Flow" "cond" "Control Flow"
|
||||
"case" "Control Flow" "and" "Control Flow" "or" "Control Flow"
|
||||
"let" "Binding" "let*" "Binding" "letrec" "Binding"
|
||||
"define" "Binding" "set!" "Binding"
|
||||
"lambda" "Functions & Components" "fn" "Functions & Components"
|
||||
"defcomp" "Functions & Components" "defmacro" "Functions & Components"
|
||||
"begin" "Sequencing & Threading" "do" "Sequencing & Threading"
|
||||
"->" "Sequencing & Threading"
|
||||
"quote" "Quoting" "quasiquote" "Quoting"
|
||||
"reset" "Continuations" "shift" "Continuations"
|
||||
"dynamic-wind" "Guards"
|
||||
"map" "Higher-Order Forms" "map-indexed" "Higher-Order Forms"
|
||||
"filter" "Higher-Order Forms" "reduce" "Higher-Order Forms"
|
||||
"some" "Higher-Order Forms" "every?" "Higher-Order Forms"
|
||||
"for-each" "Higher-Order Forms"
|
||||
"defstyle" "Domain Definitions"
|
||||
"defhandler" "Domain Definitions" "defpage" "Domain Definitions"
|
||||
"defquery" "Domain Definitions" "defaction" "Domain Definitions"})
|
||||
|
||||
|
||||
(define extract-define-kwargs
|
||||
(fn ((expr :as list))
|
||||
;; Extract keyword args from a define-special-form expression.
|
||||
;; Returns dict of keyword-name → string value.
|
||||
;; Walks items pairwise: when item[i] is a keyword, item[i+1] is its value.
|
||||
(let ((result {})
|
||||
(items (slice expr 2))
|
||||
(n (len items)))
|
||||
(for-each
|
||||
(fn ((idx :as number))
|
||||
(when (and (< (+ idx 1) n)
|
||||
(= (type-of (nth items idx)) "keyword"))
|
||||
(let ((key (keyword-name (nth items idx)))
|
||||
(val (nth items (+ idx 1))))
|
||||
(dict-set! result key
|
||||
(if (= (type-of val) "list")
|
||||
(str "(" (join " " (map serialize val)) ")")
|
||||
(str val))))))
|
||||
(range 0 n))
|
||||
result)))
|
||||
|
||||
|
||||
(define categorize-special-forms
|
||||
(fn ((parsed-exprs :as list))
|
||||
;; parsed-exprs: result of parse-all on special-forms.sx
|
||||
;; Returns dict of category-name → list of form dicts.
|
||||
(let ((categories {}))
|
||||
(for-each
|
||||
(fn (expr)
|
||||
(when (and (= (type-of expr) "list")
|
||||
(>= (len expr) 2)
|
||||
(= (type-of (first expr)) "symbol")
|
||||
(= (symbol-name (first expr)) "define-special-form"))
|
||||
(let ((name (nth expr 1))
|
||||
(kwargs (extract-define-kwargs expr))
|
||||
(category (or (get special-form-category-map name) "Other")))
|
||||
(when (not (has-key? categories category))
|
||||
(dict-set! categories category (list)))
|
||||
(append! (get categories category)
|
||||
{"name" name
|
||||
"syntax" (or (get kwargs "syntax") "")
|
||||
"doc" (or (get kwargs "doc") "")
|
||||
"tail-position" (or (get kwargs "tail-position") "")
|
||||
"example" (or (get kwargs "example") "")}))))
|
||||
parsed-exprs)
|
||||
categories)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; build-reference-data
|
||||
;;
|
||||
;; Takes a slug and raw reference data, returns structured dict for rendering.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define build-ref-items-with-href
|
||||
(fn ((items :as list) (base-path :as string) (detail-keys :as list) (n-fields :as number))
|
||||
;; items: list of lists (tuples), each with n-fields elements
|
||||
;; base-path: e.g. "/geography/hypermedia/reference/attributes/"
|
||||
;; detail-keys: list of strings (keys that have detail pages)
|
||||
;; n-fields: 2 or 3 (number of fields per tuple)
|
||||
(map
|
||||
(fn ((item :as list))
|
||||
(if (= n-fields 3)
|
||||
;; [name, desc/value, exists/desc]
|
||||
(let ((name (nth item 0))
|
||||
(field2 (nth item 1))
|
||||
(field3 (nth item 2)))
|
||||
{"name" name
|
||||
"desc" field2
|
||||
"exists" field3
|
||||
"href" (if (and field3 (some (fn ((k :as string)) (= k name)) detail-keys))
|
||||
(str base-path name)
|
||||
nil)})
|
||||
;; [name, desc]
|
||||
(let ((name (nth item 0))
|
||||
(desc (nth item 1)))
|
||||
{"name" name
|
||||
"desc" desc
|
||||
"href" (if (some (fn ((k :as string)) (= k name)) detail-keys)
|
||||
(str base-path name)
|
||||
nil)})))
|
||||
items)))
|
||||
|
||||
|
||||
(define build-reference-data
|
||||
(fn ((slug :as string) (raw-data :as dict) (detail-keys :as list))
|
||||
;; slug: "attributes", "headers", "events", "js-api"
|
||||
;; raw-data: dict with the raw data lists for this slug
|
||||
;; detail-keys: list of names that have detail pages
|
||||
(case slug
|
||||
"attributes"
|
||||
{"req-attrs" (build-ref-items-with-href
|
||||
(get raw-data "req-attrs")
|
||||
"/geography/hypermedia/reference/attributes/" detail-keys 3)
|
||||
"beh-attrs" (build-ref-items-with-href
|
||||
(get raw-data "beh-attrs")
|
||||
"/geography/hypermedia/reference/attributes/" detail-keys 3)
|
||||
"uniq-attrs" (build-ref-items-with-href
|
||||
(get raw-data "uniq-attrs")
|
||||
"/geography/hypermedia/reference/attributes/" detail-keys 3)}
|
||||
|
||||
"headers"
|
||||
{"req-headers" (build-ref-items-with-href
|
||||
(get raw-data "req-headers")
|
||||
"/geography/hypermedia/reference/headers/" detail-keys 3)
|
||||
"resp-headers" (build-ref-items-with-href
|
||||
(get raw-data "resp-headers")
|
||||
"/geography/hypermedia/reference/headers/" detail-keys 3)}
|
||||
|
||||
"events"
|
||||
{"events-list" (build-ref-items-with-href
|
||||
(get raw-data "events-list")
|
||||
"/geography/hypermedia/reference/events/" detail-keys 2)}
|
||||
|
||||
"js-api"
|
||||
{"js-api-list" (map (fn ((item :as list)) {"name" (nth item 0) "desc" (nth item 1)})
|
||||
(get raw-data "js-api-list"))}
|
||||
|
||||
;; default: attributes
|
||||
:else
|
||||
{"req-attrs" (build-ref-items-with-href
|
||||
(get raw-data "req-attrs")
|
||||
"/geography/hypermedia/reference/attributes/" detail-keys 3)
|
||||
"beh-attrs" (build-ref-items-with-href
|
||||
(get raw-data "beh-attrs")
|
||||
"/geography/hypermedia/reference/attributes/" detail-keys 3)
|
||||
"uniq-attrs" (build-ref-items-with-href
|
||||
(get raw-data "uniq-attrs")
|
||||
"/geography/hypermedia/reference/attributes/" detail-keys 3)})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; build-attr-detail / build-header-detail / build-event-detail
|
||||
;;
|
||||
;; Lookup a slug in a detail dict, reshape for page rendering.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define build-attr-detail
|
||||
(fn ((slug :as string) detail)
|
||||
;; detail: dict with "description", "example", "handler", "demo" keys or nil
|
||||
(if (nil? detail)
|
||||
{"attr-not-found" true}
|
||||
{"attr-not-found" nil
|
||||
"attr-title" slug
|
||||
"attr-description" (get detail "description")
|
||||
"attr-example" (get detail "example")
|
||||
"attr-handler" (get detail "handler")
|
||||
"attr-demo" (get detail "demo")
|
||||
"attr-wire-id" (if (has-key? detail "handler")
|
||||
(str "ref-wire-"
|
||||
(replace (replace slug ":" "-") "*" "star"))
|
||||
nil)})))
|
||||
|
||||
|
||||
(define build-header-detail
|
||||
(fn ((slug :as string) detail)
|
||||
(if (nil? detail)
|
||||
{"header-not-found" true}
|
||||
{"header-not-found" nil
|
||||
"header-title" slug
|
||||
"header-direction" (get detail "direction")
|
||||
"header-description" (get detail "description")
|
||||
"header-example" (get detail "example")
|
||||
"header-demo" (get detail "demo")})))
|
||||
|
||||
|
||||
(define build-event-detail
|
||||
(fn ((slug :as string) detail)
|
||||
(if (nil? detail)
|
||||
{"event-not-found" true}
|
||||
{"event-not-found" nil
|
||||
"event-title" slug
|
||||
"event-description" (get detail "description")
|
||||
"event-example" (get detail "example")
|
||||
"event-demo" (get detail "demo")})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; build-component-source
|
||||
;;
|
||||
;; Reconstruct defcomp/defisland source from component metadata.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define build-component-source
|
||||
(fn ((comp-data :as dict))
|
||||
;; comp-data: dict with "type", "name", "params", "has-children", "body-sx", "affinity"
|
||||
(let ((comp-type (get comp-data "type"))
|
||||
(name (get comp-data "name"))
|
||||
(params (get comp-data "params"))
|
||||
(has-children (get comp-data "has-children"))
|
||||
(body-sx (get comp-data "body-sx"))
|
||||
(affinity (get comp-data "affinity")))
|
||||
(if (= comp-type "not-found")
|
||||
(str ";; component " name " not found")
|
||||
(let ((param-strs (if (empty? params)
|
||||
(if has-children
|
||||
(list "&rest" "children")
|
||||
(list))
|
||||
(if has-children
|
||||
(append (cons "&key" params) (list "&rest" "children"))
|
||||
(cons "&key" params))))
|
||||
(params-sx (str "(" (join " " param-strs) ")"))
|
||||
(form-name (if (= comp-type "island") "defisland" "defcomp"))
|
||||
(affinity-str (if (and (= comp-type "component")
|
||||
(not (nil? affinity))
|
||||
(not (= affinity "auto")))
|
||||
(str " :affinity " affinity)
|
||||
"")))
|
||||
(str "(" form-name " " name " " params-sx affinity-str "\n " body-sx ")"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; build-bundle-analysis
|
||||
;;
|
||||
;; Compute per-page bundle stats from pre-extracted component data.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define build-bundle-analysis
|
||||
(fn ((pages-raw :as list) (components-raw :as dict) (total-components :as number) (total-macros :as number) (pure-count :as number) (io-count :as number))
|
||||
;; pages-raw: list of {:name :path :direct :needed-names}
|
||||
;; components-raw: dict of name → {:is-pure :affinity :render-target :io-refs :deps :source}
|
||||
(let ((pages-data (list)))
|
||||
(for-each
|
||||
(fn ((page :as dict))
|
||||
(let ((needed-names (get page "needed-names"))
|
||||
(n (len needed-names))
|
||||
(pct (if (> total-components 0)
|
||||
(round (* (/ n total-components) 100))
|
||||
0))
|
||||
(savings (- 100 pct))
|
||||
(pure-in-page 0)
|
||||
(io-in-page 0)
|
||||
(page-io-refs (list))
|
||||
(comp-details (list)))
|
||||
;; Walk needed components
|
||||
(for-each
|
||||
(fn ((comp-name :as string))
|
||||
(let ((info (get components-raw comp-name)))
|
||||
(when (not (nil? info))
|
||||
(if (get info "is-pure")
|
||||
(set! pure-in-page (+ pure-in-page 1))
|
||||
(do
|
||||
(set! io-in-page (+ io-in-page 1))
|
||||
(for-each
|
||||
(fn ((ref :as string)) (when (not (some (fn ((r :as string)) (= r ref)) page-io-refs))
|
||||
(append! page-io-refs ref)))
|
||||
(or (get info "io-refs") (list)))))
|
||||
(append! comp-details
|
||||
{"name" comp-name
|
||||
"is-pure" (get info "is-pure")
|
||||
"affinity" (get info "affinity")
|
||||
"render-target" (get info "render-target")
|
||||
"io-refs" (or (get info "io-refs") (list))
|
||||
"deps" (or (get info "deps") (list))
|
||||
"source" (get info "source")}))))
|
||||
needed-names)
|
||||
(append! pages-data
|
||||
{"name" (get page "name")
|
||||
"path" (get page "path")
|
||||
"direct" (get page "direct")
|
||||
"needed" n
|
||||
"pct" pct
|
||||
"savings" savings
|
||||
"io-refs" (len page-io-refs)
|
||||
"pure-in-page" pure-in-page
|
||||
"io-in-page" io-in-page
|
||||
"components" comp-details})))
|
||||
pages-raw)
|
||||
{"pages" pages-data
|
||||
"total-components" total-components
|
||||
"total-macros" total-macros
|
||||
"pure-count" pure-count
|
||||
"io-count" io-count})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; build-routing-analysis
|
||||
;;
|
||||
;; Classify pages by routing mode (client vs server).
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define build-routing-analysis
|
||||
(fn ((pages-raw :as list))
|
||||
;; pages-raw: list of {:name :path :has-data :content-src}
|
||||
(let ((pages-data (list))
|
||||
(client-count 0)
|
||||
(server-count 0))
|
||||
(for-each
|
||||
(fn ((page :as dict))
|
||||
(let ((has-data (get page "has-data"))
|
||||
(content-src (or (get page "content-src") ""))
|
||||
(mode nil)
|
||||
(reason ""))
|
||||
(cond
|
||||
has-data
|
||||
(do (set! mode "server")
|
||||
(set! reason "Has :data expression — needs server IO")
|
||||
(set! server-count (+ server-count 1)))
|
||||
(empty? content-src)
|
||||
(do (set! mode "server")
|
||||
(set! reason "No content expression")
|
||||
(set! server-count (+ server-count 1)))
|
||||
:else
|
||||
(do (set! mode "client")
|
||||
(set! client-count (+ client-count 1))))
|
||||
(append! pages-data
|
||||
{"name" (get page "name")
|
||||
"path" (get page "path")
|
||||
"mode" mode
|
||||
"has-data" has-data
|
||||
"content-expr" (if (> (len content-src) 80)
|
||||
(str (slice content-src 0 80) "...")
|
||||
content-src)
|
||||
"reason" reason})))
|
||||
pages-raw)
|
||||
{"pages" pages-data
|
||||
"total-pages" (+ client-count server-count)
|
||||
"client-count" client-count
|
||||
"server-count" server-count})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; build-affinity-analysis
|
||||
;;
|
||||
;; Package component affinity info + page render plans for display.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define build-affinity-analysis
|
||||
(fn ((demo-components :as list) (page-plans :as list))
|
||||
{"components" demo-components
|
||||
"page-plans" page-plans}))
|
||||
680
web/router.sx
Normal file
680
web/router.sx
Normal file
@@ -0,0 +1,680 @@
|
||||
;; ==========================================================================
|
||||
;; router.sx — Client-side route matching specification
|
||||
;;
|
||||
;; Pure functions for matching URL paths against Flask-style route patterns.
|
||||
;; Used by client-side routing to determine if a page can be rendered
|
||||
;; locally without a server roundtrip.
|
||||
;;
|
||||
;; All functions are pure — no IO, no platform-specific operations.
|
||||
;; Uses only primitives from primitives.sx (string ops, list ops).
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Split path into segments
|
||||
;; --------------------------------------------------------------------------
|
||||
;; "/docs/hello" → ("docs" "hello")
|
||||
;; "/" → ()
|
||||
;; "/docs/" → ("docs")
|
||||
|
||||
(define split-path-segments :effects []
|
||||
(fn ((path :as string))
|
||||
(let ((trimmed (if (starts-with? path "/") (slice path 1) path)))
|
||||
(let ((trimmed2 (if (and (not (empty? trimmed))
|
||||
(ends-with? trimmed "/"))
|
||||
(slice trimmed 0 (- (len trimmed) 1))
|
||||
trimmed)))
|
||||
(if (empty? trimmed2)
|
||||
(list)
|
||||
(split trimmed2 "/"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Parse Flask-style route pattern into segment descriptors
|
||||
;; --------------------------------------------------------------------------
|
||||
;; "/docs/<slug>" → ({"type" "literal" "value" "docs"}
|
||||
;; {"type" "param" "value" "slug"})
|
||||
|
||||
(define make-route-segment :effects []
|
||||
(fn ((seg :as string))
|
||||
(if (and (starts-with? seg "<") (ends-with? seg ">"))
|
||||
(let ((param-name (slice seg 1 (- (len seg) 1))))
|
||||
(let ((d {}))
|
||||
(dict-set! d "type" "param")
|
||||
(dict-set! d "value" param-name)
|
||||
d))
|
||||
(let ((d {}))
|
||||
(dict-set! d "type" "literal")
|
||||
(dict-set! d "value" seg)
|
||||
d))))
|
||||
|
||||
(define parse-route-pattern :effects []
|
||||
(fn ((pattern :as string))
|
||||
(let ((segments (split-path-segments pattern)))
|
||||
(map make-route-segment segments))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Match path segments against parsed pattern
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns params dict if match, nil if no match.
|
||||
|
||||
(define match-route-segments :effects []
|
||||
(fn ((path-segs :as list) (parsed-segs :as list))
|
||||
(if (not (= (len path-segs) (len parsed-segs)))
|
||||
nil
|
||||
(let ((params {})
|
||||
(matched true))
|
||||
(for-each-indexed
|
||||
(fn ((i :as number) (parsed-seg :as dict))
|
||||
(when matched
|
||||
(let ((path-seg (nth path-segs i))
|
||||
(seg-type (get parsed-seg "type")))
|
||||
(cond
|
||||
(= seg-type "literal")
|
||||
(when (not (= path-seg (get parsed-seg "value")))
|
||||
(set! matched false))
|
||||
(= seg-type "param")
|
||||
(dict-set! params (get parsed-seg "value") path-seg)
|
||||
:else
|
||||
(set! matched false)))))
|
||||
parsed-segs)
|
||||
(if matched params nil)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Public API: match a URL path against a pattern string
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns params dict (may be empty for exact matches) or nil.
|
||||
|
||||
(define match-route :effects []
|
||||
(fn ((path :as string) (pattern :as string))
|
||||
(let ((path-segs (split-path-segments path))
|
||||
(parsed-segs (parse-route-pattern pattern)))
|
||||
(match-route-segments path-segs parsed-segs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Search a list of route entries for first match
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Each entry: {"pattern" "/docs/<slug>" "parsed" [...] "name" "docs-page" ...}
|
||||
;; Returns matching entry with "params" added, or nil.
|
||||
|
||||
(define find-matching-route :effects []
|
||||
(fn ((path :as string) (routes :as list))
|
||||
;; If path is an SX expression URL, convert to old-style for matching.
|
||||
(let ((match-path (if (starts-with? path "/(")
|
||||
(or (sx-url-to-path path) path)
|
||||
path)))
|
||||
(let ((path-segs (split-path-segments match-path))
|
||||
(result nil))
|
||||
(for-each
|
||||
(fn ((route :as dict))
|
||||
(when (nil? result)
|
||||
(let ((params (match-route-segments path-segs (get route "parsed"))))
|
||||
(when (not (nil? params))
|
||||
(let ((matched (merge route {})))
|
||||
(dict-set! matched "params" params)
|
||||
(set! result matched))))))
|
||||
routes)
|
||||
result))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. SX expression URL → old-style path conversion
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Converts /(language.(doc.introduction)) → /language/docs/introduction
|
||||
;; so client-side routing can match SX URLs against Flask-style patterns.
|
||||
|
||||
(define _fn-to-segment :effects []
|
||||
(fn ((name :as string))
|
||||
(case name
|
||||
"doc" "docs"
|
||||
"spec" "specs"
|
||||
"bootstrapper" "bootstrappers"
|
||||
"test" "testing"
|
||||
"example" "examples"
|
||||
"protocol" "protocols"
|
||||
"essay" "essays"
|
||||
"plan" "plans"
|
||||
"reference-detail" "reference"
|
||||
:else name)))
|
||||
|
||||
(define sx-url-to-path :effects []
|
||||
(fn ((url :as string))
|
||||
;; Convert an SX expression URL to an old-style slash path.
|
||||
;; "/(language.(doc.introduction))" → "/language/docs/introduction"
|
||||
;; Returns nil for non-SX URLs (those not starting with "/(" ).
|
||||
(if (not (and (starts-with? url "/(") (ends-with? url ")")))
|
||||
nil
|
||||
(let ((inner (slice url 2 (- (len url) 1))))
|
||||
;; "language.(doc.introduction)" → dots to slashes, strip parens
|
||||
(let ((s (replace (replace (replace inner "." "/") "(" "") ")" "")))
|
||||
;; "language/doc/introduction" → split, map names, rejoin
|
||||
(let ((segs (filter (fn (s) (not (empty? s))) (split s "/"))))
|
||||
(str "/" (join "/" (map _fn-to-segment segs)))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. Relative SX URL resolution
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Resolves relative SX URLs against the current absolute URL.
|
||||
;; This is a macro in the deepest sense: SX transforming SX into SX.
|
||||
;; The URL is code. Relative resolution is code transformation.
|
||||
;;
|
||||
;; Relative URLs start with ( or . :
|
||||
;; (.slug) → append slug as argument to innermost call
|
||||
;; (..section) → up 1: replace innermost with new nested call
|
||||
;; (...section) → up 2: replace 2 innermost levels
|
||||
;;
|
||||
;; Bare-dot shorthand (parens optional):
|
||||
;; .slug → same as (.slug)
|
||||
;; .. → same as (..) — go up one level
|
||||
;; ... → same as (...) — go up two levels
|
||||
;; .:page.4 → same as (.:page.4) — set keyword
|
||||
;;
|
||||
;; Dot count semantics (parallels filesystem . and ..):
|
||||
;; 1 dot = current level (append argument / modify keyword)
|
||||
;; 2 dots = up 1 level (sibling call)
|
||||
;; 3 dots = up 2 levels
|
||||
;; N dots = up N-1 levels
|
||||
;;
|
||||
;; Keyword operations (set, delta):
|
||||
;; (.:page.4) → set :page to 4 at current level
|
||||
;; (.:page.+1) → increment :page by 1 (delta)
|
||||
;; (.:page.-1) → decrement :page by 1 (delta)
|
||||
;; (.slug.:page.1) → append slug AND set :page=1
|
||||
;;
|
||||
;; Examples (current = "/(geography.(hypermedia.(example)))"):
|
||||
;; (.progress-bar) → /(geography.(hypermedia.(example.progress-bar)))
|
||||
;; (..reactive.demo) → /(geography.(hypermedia.(reactive.demo)))
|
||||
;; (...marshes) → /(geography.(marshes))
|
||||
;; (..) → /(geography.(hypermedia))
|
||||
;; (...) → /(geography)
|
||||
;;
|
||||
;; Keyword examples (current = "/(language.(spec.(explore.signals.:page.3)))"):
|
||||
;; (.:page.4) → /(language.(spec.(explore.signals.:page.4)))
|
||||
;; (.:page.+1) → /(language.(spec.(explore.signals.:page.4)))
|
||||
;; (.:page.-1) → /(language.(spec.(explore.signals.:page.2)))
|
||||
;; (..eval) → /(language.(spec.(eval)))
|
||||
;; (..eval.:page.1) → /(language.(spec.(eval.:page.1)))
|
||||
|
||||
(define _count-leading-dots :effects []
|
||||
(fn ((s :as string))
|
||||
(if (empty? s)
|
||||
0
|
||||
(if (starts-with? s ".")
|
||||
(+ 1 (_count-leading-dots (slice s 1)))
|
||||
0))))
|
||||
|
||||
(define _strip-trailing-close :effects []
|
||||
(fn ((s :as string))
|
||||
;; Strip trailing ) characters: "/(a.(b.(c" from "/(a.(b.(c)))"
|
||||
(if (ends-with? s ")")
|
||||
(_strip-trailing-close (slice s 0 (- (len s) 1)))
|
||||
s)))
|
||||
|
||||
(define _index-of-safe :effects []
|
||||
(fn ((s :as string) (needle :as string))
|
||||
;; Wrapper around index-of that normalizes -1 to nil.
|
||||
;; (index-of returns -1 on some platforms, nil on others.)
|
||||
(let ((idx (index-of s needle)))
|
||||
(if (or (nil? idx) (< idx 0)) nil idx))))
|
||||
|
||||
(define _last-index-of :effects []
|
||||
(fn ((s :as string) (needle :as string))
|
||||
;; Find the last occurrence of needle in s. Returns nil if not found.
|
||||
(let ((idx (_index-of-safe s needle)))
|
||||
(if (nil? idx)
|
||||
nil
|
||||
(let ((rest-idx (_last-index-of (slice s (+ idx 1)) needle)))
|
||||
(if (nil? rest-idx)
|
||||
idx
|
||||
(+ (+ idx 1) rest-idx)))))))
|
||||
|
||||
(define _pop-sx-url-level :effects []
|
||||
(fn ((url :as string))
|
||||
;; Remove the innermost nesting level from an absolute SX URL.
|
||||
;; "/(a.(b.(c)))" → "/(a.(b))"
|
||||
;; "/(a.(b))" → "/(a)"
|
||||
;; "/(a)" → "/"
|
||||
(let ((stripped (_strip-trailing-close url))
|
||||
(close-count (- (len url) (len (_strip-trailing-close url)))))
|
||||
(if (<= close-count 1)
|
||||
"/" ;; at root, popping goes to bare root
|
||||
(let ((last-dp (_last-index-of stripped ".(")))
|
||||
(if (nil? last-dp)
|
||||
"/" ;; single-level URL, pop to root
|
||||
;; Remove from .( to end of stripped, drop one closing paren
|
||||
(str (slice stripped 0 last-dp)
|
||||
(slice url (- (len url) (- close-count 1))))))))))
|
||||
|
||||
(define _pop-sx-url-levels :effects []
|
||||
(fn ((url :as string) (n :as number))
|
||||
(if (<= n 0)
|
||||
url
|
||||
(_pop-sx-url-levels (_pop-sx-url-level url) (- n 1)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. Relative URL body parsing — positional vs keyword tokens
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Body "slug.:page.4" → positional "slug", keywords ((:page 4))
|
||||
;; Body ":page.+1" → positional "", keywords ((:page +1))
|
||||
|
||||
(define _split-pos-kw :effects []
|
||||
(fn ((tokens :as list) (i :as number) (pos :as list) (kw :as list))
|
||||
;; Walk tokens: non-: tokens are positional, : tokens consume next as value
|
||||
(if (>= i (len tokens))
|
||||
{"positional" (join "." pos) "keywords" kw}
|
||||
(let ((tok (nth tokens i)))
|
||||
(if (starts-with? tok ":")
|
||||
;; Keyword: take this + next token as a pair
|
||||
(let ((val (if (< (+ i 1) (len tokens))
|
||||
(nth tokens (+ i 1))
|
||||
"")))
|
||||
(_split-pos-kw tokens (+ i 2) pos
|
||||
(append kw (list (list tok val)))))
|
||||
;; Positional token
|
||||
(_split-pos-kw tokens (+ i 1)
|
||||
(append pos (list tok))
|
||||
kw))))))
|
||||
|
||||
(define _parse-relative-body :effects []
|
||||
(fn ((body :as string))
|
||||
;; Returns {"positional" <string> "keywords" <list of (kw val) pairs>}
|
||||
(if (empty? body)
|
||||
{"positional" "" "keywords" (list)}
|
||||
(_split-pos-kw (split body ".") 0 (list) (list)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. Keyword operations on URL expressions
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Extract, find, and modify keyword arguments in the innermost expression.
|
||||
|
||||
(define _extract-innermost :effects []
|
||||
(fn ((url :as string))
|
||||
;; Returns {"before" ... "content" ... "suffix" ...}
|
||||
;; where before + content + suffix = url
|
||||
;; content = the innermost expression's dot-separated tokens
|
||||
(let ((stripped (_strip-trailing-close url))
|
||||
(suffix (slice url (len (_strip-trailing-close url)))))
|
||||
(let ((last-dp (_last-index-of stripped ".(")))
|
||||
(if (nil? last-dp)
|
||||
;; Single-level: /(content)
|
||||
{"before" "/("
|
||||
"content" (slice stripped 2)
|
||||
"suffix" suffix}
|
||||
;; Multi-level: .../.(content)...)
|
||||
{"before" (slice stripped 0 (+ last-dp 2))
|
||||
"content" (slice stripped (+ last-dp 2))
|
||||
"suffix" suffix})))))
|
||||
|
||||
(define _find-kw-in-tokens :effects []
|
||||
(fn ((tokens :as list) (i :as number) (kw :as string))
|
||||
;; Find value of keyword kw in token list. Returns nil if not found.
|
||||
(if (>= i (len tokens))
|
||||
nil
|
||||
(if (and (= (nth tokens i) kw)
|
||||
(< (+ i 1) (len tokens)))
|
||||
(nth tokens (+ i 1))
|
||||
(_find-kw-in-tokens tokens (+ i 1) kw)))))
|
||||
|
||||
(define _find-keyword-value :effects []
|
||||
(fn ((content :as string) (kw :as string))
|
||||
;; Find keyword's value in dot-separated content string.
|
||||
;; "explore.signals.:page.3" ":page" → "3"
|
||||
(_find-kw-in-tokens (split content ".") 0 kw)))
|
||||
|
||||
(define _replace-kw-in-tokens :effects []
|
||||
(fn ((tokens :as list) (i :as number) (kw :as string) (value :as string))
|
||||
;; Replace keyword's value in token list. Returns new token list.
|
||||
(if (>= i (len tokens))
|
||||
(list)
|
||||
(if (and (= (nth tokens i) kw)
|
||||
(< (+ i 1) (len tokens)))
|
||||
;; Found — keep keyword, replace value, concat rest
|
||||
(append (list kw value)
|
||||
(_replace-kw-in-tokens tokens (+ i 2) kw value))
|
||||
;; Not this keyword — keep token, continue
|
||||
(cons (nth tokens i)
|
||||
(_replace-kw-in-tokens tokens (+ i 1) kw value))))))
|
||||
|
||||
(define _set-keyword-in-content :effects []
|
||||
(fn ((content :as string) (kw :as string) (value :as string))
|
||||
;; Set or replace keyword value in dot-separated content.
|
||||
;; "a.b.:page.3" ":page" "4" → "a.b.:page.4"
|
||||
;; "a.b" ":page" "1" → "a.b.:page.1"
|
||||
(let ((current (_find-keyword-value content kw)))
|
||||
(if (nil? current)
|
||||
;; Not found — append
|
||||
(str content "." kw "." value)
|
||||
;; Found — replace
|
||||
(join "." (_replace-kw-in-tokens (split content ".") 0 kw value))))))
|
||||
|
||||
(define _is-delta-value? :effects []
|
||||
(fn ((s :as string))
|
||||
;; "+1", "-2", "+10" are deltas. "-" alone is not.
|
||||
(and (not (empty? s))
|
||||
(> (len s) 1)
|
||||
(or (starts-with? s "+") (starts-with? s "-")))))
|
||||
|
||||
(define _apply-delta :effects []
|
||||
(fn ((current-str :as string) (delta-str :as string))
|
||||
;; Apply numeric delta to current value string.
|
||||
;; "3" "+1" → "4", "3" "-1" → "2"
|
||||
(let ((cur (parse-int current-str nil))
|
||||
(delta (parse-int delta-str nil)))
|
||||
(if (or (nil? cur) (nil? delta))
|
||||
delta-str ;; fallback: use delta as literal value
|
||||
(str (+ cur delta))))))
|
||||
|
||||
(define _apply-kw-pairs :effects []
|
||||
(fn ((content :as string) (kw-pairs :as list))
|
||||
;; Apply keyword modifications to content, one at a time.
|
||||
(if (empty? kw-pairs)
|
||||
content
|
||||
(let ((pair (first kw-pairs))
|
||||
(kw (first pair))
|
||||
(raw-val (nth pair 1)))
|
||||
(let ((actual-val
|
||||
(if (_is-delta-value? raw-val)
|
||||
(let ((current (_find-keyword-value content kw)))
|
||||
(if (nil? current)
|
||||
raw-val ;; no current value, treat delta as literal
|
||||
(_apply-delta current raw-val)))
|
||||
raw-val)))
|
||||
(_apply-kw-pairs
|
||||
(_set-keyword-in-content content kw actual-val)
|
||||
(rest kw-pairs)))))))
|
||||
|
||||
(define _apply-keywords-to-url :effects []
|
||||
(fn ((url :as string) (kw-pairs :as list))
|
||||
;; Apply keyword modifications to the innermost expression of a URL.
|
||||
(if (empty? kw-pairs)
|
||||
url
|
||||
(let ((parts (_extract-innermost url)))
|
||||
(let ((new-content (_apply-kw-pairs (get parts "content") kw-pairs)))
|
||||
(str (get parts "before") new-content (get parts "suffix")))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 10. Public API: resolve-relative-url (structural + keywords)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define _normalize-relative :effects []
|
||||
(fn ((url :as string))
|
||||
;; Normalize bare-dot shorthand to paren form.
|
||||
;; ".." → "(..)"
|
||||
;; ".slug" → "(.slug)"
|
||||
;; ".:page.4" → "(.:page.4)"
|
||||
;; "(.slug)" → "(.slug)" (already canonical)
|
||||
(if (starts-with? url "(")
|
||||
url
|
||||
(str "(" url ")"))))
|
||||
|
||||
(define resolve-relative-url :effects []
|
||||
(fn ((current :as string) (relative :as string))
|
||||
;; current: absolute SX URL "/(geography.(hypermedia.(example)))"
|
||||
;; relative: relative SX URL "(.progress-bar)" or ".." or ".:page.+1"
|
||||
;; Returns: absolute SX URL
|
||||
(let ((canonical (_normalize-relative relative)))
|
||||
(let ((rel-inner (slice canonical 1 (- (len canonical) 1))))
|
||||
(let ((dots (_count-leading-dots rel-inner))
|
||||
(body (slice rel-inner (_count-leading-dots rel-inner))))
|
||||
(if (= dots 0)
|
||||
current ;; no dots — not a relative URL
|
||||
;; Parse body into positional part + keyword pairs
|
||||
(let ((parsed (_parse-relative-body body))
|
||||
(pos-body (get parsed "positional"))
|
||||
(kw-pairs (get parsed "keywords")))
|
||||
;; Step 1: structural navigation
|
||||
(let ((after-nav
|
||||
(if (= dots 1)
|
||||
;; One dot = current level
|
||||
(if (empty? pos-body)
|
||||
current ;; no positional → stay here (keyword-only)
|
||||
;; Append positional part at current level
|
||||
(let ((stripped (_strip-trailing-close current))
|
||||
(suffix (slice current (len (_strip-trailing-close current)))))
|
||||
(str stripped "." pos-body suffix)))
|
||||
;; Two+ dots = pop (dots-1) levels
|
||||
(let ((base (_pop-sx-url-levels current (- dots 1))))
|
||||
(if (empty? pos-body)
|
||||
base ;; no positional → just pop (cd ..)
|
||||
(if (= base "/")
|
||||
(str "/(" pos-body ")")
|
||||
(let ((stripped (_strip-trailing-close base))
|
||||
(suffix (slice base (len (_strip-trailing-close base)))))
|
||||
(str stripped ".(" pos-body ")" suffix))))))))
|
||||
;; Step 2: apply keyword modifications
|
||||
(_apply-keywords-to-url after-nav kw-pairs)))))))))
|
||||
|
||||
;; Check if a URL is relative (starts with ( but not /( , or starts with .)
|
||||
(define relative-sx-url? :effects []
|
||||
(fn ((url :as string))
|
||||
(or (and (starts-with? url "(")
|
||||
(not (starts-with? url "/(")))
|
||||
(starts-with? url "."))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 11. URL special forms (! prefix)
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Special forms are meta-operations on URL expressions.
|
||||
;; Distinguished by `!` prefix to avoid name collisions with sections/pages.
|
||||
;;
|
||||
;; Known forms:
|
||||
;; !source — show defcomp source code
|
||||
;; !inspect — deps, CSS footprint, render plan, IO
|
||||
;; !diff — side-by-side comparison of two expressions
|
||||
;; !search — grep within a page/spec
|
||||
;; !raw — skip ~sx-doc wrapping, return raw content
|
||||
;; !json — return content as JSON data
|
||||
;;
|
||||
;; URL examples:
|
||||
;; /(!source.(~essay-sx-sucks))
|
||||
;; /(!inspect.(language.(doc.primitives)))
|
||||
;; /(!diff.(language.(spec.signals)).(language.(spec.eval)))
|
||||
;; /(!search."define".:in.(language.(spec.signals)))
|
||||
;; /(!raw.(~some-component))
|
||||
;; /(!json.(language.(doc.primitives)))
|
||||
|
||||
(define _url-special-forms :effects []
|
||||
(fn ()
|
||||
;; Returns the set of known URL special form names.
|
||||
(list "!source" "!inspect" "!diff" "!search" "!raw" "!json")))
|
||||
|
||||
(define url-special-form? :effects []
|
||||
(fn ((name :as string))
|
||||
;; Check if a name is a URL special form (starts with ! and is known).
|
||||
(and (starts-with? name "!")
|
||||
(contains? (_url-special-forms) name))))
|
||||
|
||||
(define parse-sx-url :effects []
|
||||
(fn ((url :as string))
|
||||
;; Parse an SX URL into a structured descriptor.
|
||||
;; Returns a dict with:
|
||||
;; "type" — "home" | "absolute" | "relative" | "special-form" | "direct-component"
|
||||
;; "form" — special form name (for special-form type), e.g. "!source"
|
||||
;; "inner" — inner URL expression string (without the special form wrapper)
|
||||
;; "raw" — original URL string
|
||||
;;
|
||||
;; Examples:
|
||||
;; "/" → {"type" "home" "raw" "/"}
|
||||
;; "/(language.(doc.intro))" → {"type" "absolute" "raw" ...}
|
||||
;; "(.slug)" → {"type" "relative" "raw" ...}
|
||||
;; "..slug" → {"type" "relative" "raw" ...}
|
||||
;; "/(!source.(~essay))" → {"type" "special-form" "form" "!source" "inner" "(~essay)" "raw" ...}
|
||||
;; "/(~essay-sx-sucks)" → {"type" "direct-component" "name" "~essay-sx-sucks" "raw" ...}
|
||||
(cond
|
||||
(= url "/")
|
||||
{"type" "home" "raw" url}
|
||||
(relative-sx-url? url)
|
||||
{"type" "relative" "raw" url}
|
||||
(and (starts-with? url "/(!")
|
||||
(ends-with? url ")"))
|
||||
;; Special form: /(!source.(~essay)) or /(!diff.a.b)
|
||||
;; Extract the form name (first dot-separated token after /()
|
||||
(let ((inner (slice url 2 (- (len url) 1))))
|
||||
;; inner = "!source.(~essay)" or "!diff.(a).(b)"
|
||||
(let ((dot-pos (_index-of-safe inner "."))
|
||||
(paren-pos (_index-of-safe inner "(")))
|
||||
;; Form name ends at first . or ( (whichever comes first)
|
||||
(let ((end-pos (cond
|
||||
(and (nil? dot-pos) (nil? paren-pos)) (len inner)
|
||||
(nil? dot-pos) paren-pos
|
||||
(nil? paren-pos) dot-pos
|
||||
:else (min dot-pos paren-pos))))
|
||||
(let ((form-name (slice inner 0 end-pos))
|
||||
(rest-part (slice inner end-pos)))
|
||||
;; rest-part starts with "." → strip leading dot
|
||||
(let ((inner-expr (if (starts-with? rest-part ".")
|
||||
(slice rest-part 1)
|
||||
rest-part)))
|
||||
{"type" "special-form"
|
||||
"form" form-name
|
||||
"inner" inner-expr
|
||||
"raw" url})))))
|
||||
(and (starts-with? url "/(~")
|
||||
(ends-with? url ")"))
|
||||
;; Direct component: /(~essay-sx-sucks)
|
||||
(let ((name (slice url 2 (- (len url) 1))))
|
||||
{"type" "direct-component" "name" name "raw" url})
|
||||
(and (starts-with? url "/(")
|
||||
(ends-with? url ")"))
|
||||
{"type" "absolute" "raw" url}
|
||||
:else
|
||||
{"type" "path" "raw" url})))
|
||||
|
||||
(define url-special-form-name :effects []
|
||||
(fn ((url :as string))
|
||||
;; Extract the special form name from a URL, or nil if not a special form.
|
||||
;; "/(!source.(~essay))" → "!source"
|
||||
;; "/(language.(doc))" → nil
|
||||
(let ((parsed (parse-sx-url url)))
|
||||
(if (= (get parsed "type") "special-form")
|
||||
(get parsed "form")
|
||||
nil))))
|
||||
|
||||
(define url-special-form-inner :effects []
|
||||
(fn ((url :as string))
|
||||
;; Extract the inner expression from a special form URL, or nil.
|
||||
;; "/(!source.(~essay))" → "(~essay)"
|
||||
;; "/(!diff.(a).(b))" → "(a).(b)"
|
||||
(let ((parsed (parse-sx-url url)))
|
||||
(if (= (get parsed "type") "special-form")
|
||||
(get parsed "inner")
|
||||
nil))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 12. URL expression evaluation
|
||||
;; --------------------------------------------------------------------------
|
||||
;; A URL is an expression. The system is the environment.
|
||||
;; eval(url, env) — that's it.
|
||||
;;
|
||||
;; The only URL-specific pre-processing:
|
||||
;; 1. Surface syntax → AST (dots to spaces, parse as SX)
|
||||
;; 2. Auto-quote unknowns (symbols not in env become strings)
|
||||
;;
|
||||
;; After that, it's standard eval. The host wires these into its route
|
||||
;; handlers (Python catch-all, JS client-side navigation). The same
|
||||
;; functions serve both.
|
||||
|
||||
(define url-to-expr :effects []
|
||||
(fn ((url-path :as string))
|
||||
;; Convert a URL path to an SX expression (AST).
|
||||
;;
|
||||
;; "/sx/(language.(doc.introduction))" → (language (doc introduction))
|
||||
;; "/(language.(doc.introduction))" → (language (doc introduction))
|
||||
;; "/" → (list) ; empty — home
|
||||
;;
|
||||
;; Steps:
|
||||
;; 1. Strip URL prefix ("/sx/" or "/") — host passes the path after prefix
|
||||
;; 2. Dots → spaces (URL-safe whitespace encoding)
|
||||
;; 3. Parse as SX expression
|
||||
;;
|
||||
;; The caller is responsible for stripping any app-level prefix.
|
||||
;; This function receives the raw expression portion: "(language.(doc.intro))"
|
||||
;; or "/" for home.
|
||||
(if (or (= url-path "/") (empty? url-path))
|
||||
(list)
|
||||
(let ((trimmed (if (starts-with? url-path "/")
|
||||
(slice url-path 1)
|
||||
url-path)))
|
||||
;; Dots → spaces
|
||||
(let ((sx-source (replace trimmed "." " ")))
|
||||
;; Parse — returns list of expressions, take the first
|
||||
(let ((exprs (sx-parse sx-source)))
|
||||
(if (empty? exprs)
|
||||
(list)
|
||||
(first exprs))))))))
|
||||
|
||||
|
||||
(define auto-quote-unknowns :effects []
|
||||
(fn ((expr :as list) (env :as dict))
|
||||
;; Walk an AST and replace symbols not in env with their name as a string.
|
||||
;; This makes URL slugs work without quoting:
|
||||
;; (language (doc introduction)) ; introduction is not a function
|
||||
;; → (language (doc "introduction"))
|
||||
;;
|
||||
;; Rules:
|
||||
;; - List head (call position) stays as-is — it's a function name
|
||||
;; - Tail symbols: if in env, keep as symbol; otherwise, string
|
||||
;; - Keywords, strings, numbers, nested lists: pass through
|
||||
;; - Non-list expressions: pass through unchanged
|
||||
(if (not (list? expr))
|
||||
expr
|
||||
(if (empty? expr)
|
||||
expr
|
||||
;; Head stays as symbol (function position), quote the rest
|
||||
(cons (first expr)
|
||||
(map (fn (child)
|
||||
(cond
|
||||
;; Nested list — recurse
|
||||
(list? child)
|
||||
(auto-quote-unknowns child env)
|
||||
;; Symbol — check env
|
||||
(= (type-of child) "symbol")
|
||||
(let ((name (symbol-name child)))
|
||||
(if (or (env-has? env name)
|
||||
;; Keep keywords, component refs, special forms
|
||||
(starts-with? name ":")
|
||||
(starts-with? name "~")
|
||||
(starts-with? name "!"))
|
||||
child
|
||||
name)) ;; unknown → string
|
||||
;; Everything else passes through
|
||||
:else child))
|
||||
(rest expr)))))))
|
||||
|
||||
|
||||
(define prepare-url-expr :effects []
|
||||
(fn ((url-path :as string) (env :as dict))
|
||||
;; Full pipeline: URL path → ready-to-eval AST.
|
||||
;;
|
||||
;; "(language.(doc.introduction))" + env
|
||||
;; → (language (doc "introduction"))
|
||||
;;
|
||||
;; The result can be fed directly to eval:
|
||||
;; (eval (prepare-url-expr path env) env)
|
||||
(let ((expr (url-to-expr url-path)))
|
||||
(if (empty? expr)
|
||||
expr
|
||||
(auto-quote-unknowns expr env)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Pure primitives used:
|
||||
;; split, slice, starts-with?, ends-with?, len, empty?, replace,
|
||||
;; map, filter, for-each, for-each-indexed, nth, get, dict-set!, merge,
|
||||
;; list, nil?, not, =, case, join, str, index-of, and, or, cons,
|
||||
;; first, rest, append, parse-int, contains?, min, cond,
|
||||
;; symbol?, symbol-name, list?, env-has?, type-of
|
||||
;;
|
||||
;; From parser.sx: sx-parse, sx-serialize
|
||||
;; --------------------------------------------------------------------------
|
||||
479
web/signals.sx
Normal file
479
web/signals.sx
Normal file
@@ -0,0 +1,479 @@
|
||||
;; ==========================================================================
|
||||
;; signals.sx — Reactive signal runtime specification
|
||||
;;
|
||||
;; Defines the signal primitive: a container for a value that notifies
|
||||
;; subscribers when it changes. Signals are the reactive state primitive
|
||||
;; for SX islands.
|
||||
;;
|
||||
;; Signals are pure computation — no DOM, no IO. The reactive rendering
|
||||
;; layer (adapter-dom.sx) subscribes DOM nodes to signals. The server
|
||||
;; adapter (adapter-html.sx) reads signal values without subscribing.
|
||||
;;
|
||||
;; Signals are plain dicts with a "__signal" marker key. No platform
|
||||
;; primitives needed — all signal operations are pure SX.
|
||||
;;
|
||||
;; Reactive tracking and island lifecycle use the general scoped effects
|
||||
;; system (scope-push!/scope-pop!/context) instead of separate globals.
|
||||
;; Two scope names:
|
||||
;; "sx-reactive" — tracking context for computed/effect dep discovery
|
||||
;; "sx-island-scope" — island disposable collector
|
||||
;;
|
||||
;; Scope-based tracking:
|
||||
;; (scope-push! "sx-reactive" {:deps (list) :notify fn}) → void
|
||||
;; (scope-pop! "sx-reactive") → void
|
||||
;; (context "sx-reactive" nil) → dict or nil
|
||||
;;
|
||||
;; CEK callable dispatch:
|
||||
;; (cek-call f args) → any — call f with args list via CEK.
|
||||
;; Dispatches through cek-run for SX
|
||||
;; lambdas, apply for native callables.
|
||||
;; Defined in cek.sx.
|
||||
;;
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Signal container — plain dict with marker key
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; A signal is a dict: {"__signal" true, "value" v, "subscribers" [], "deps" []}
|
||||
;; type-of returns "dict". Use signal? to distinguish from regular dicts.
|
||||
|
||||
(define make-signal (fn (value)
|
||||
(dict "__signal" true "value" value "subscribers" (list) "deps" (list))))
|
||||
|
||||
(define signal? (fn (x)
|
||||
(and (dict? x) (has-key? x "__signal"))))
|
||||
|
||||
(define signal-value (fn (s) (get s "value")))
|
||||
(define signal-set-value! (fn (s v) (dict-set! s "value" v)))
|
||||
(define signal-subscribers (fn (s) (get s "subscribers")))
|
||||
|
||||
(define signal-add-sub! (fn (s f)
|
||||
(when (not (contains? (get s "subscribers") f))
|
||||
(append! (get s "subscribers") f))))
|
||||
|
||||
(define signal-remove-sub! (fn (s f)
|
||||
(dict-set! s "subscribers"
|
||||
(filter (fn (sub) (not (identical? sub f)))
|
||||
(get s "subscribers")))))
|
||||
|
||||
(define signal-deps (fn (s) (get s "deps")))
|
||||
(define signal-set-deps! (fn (s deps) (dict-set! s "deps" deps)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. signal — create a reactive container
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define signal :effects []
|
||||
(fn ((initial-value :as any))
|
||||
(make-signal initial-value)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. deref — read signal value, subscribe current reactive context
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; In a reactive context (inside effect or computed), deref registers the
|
||||
;; signal as a dependency. Outside reactive context, deref just returns
|
||||
;; the current value — no subscription, no overhead.
|
||||
|
||||
(define deref :effects []
|
||||
(fn ((s :as any))
|
||||
(if (not (signal? s))
|
||||
s ;; non-signal values pass through
|
||||
(let ((ctx (context "sx-reactive" nil)))
|
||||
(when ctx
|
||||
;; Register this signal as a dependency of the current context
|
||||
(let ((dep-list (get ctx "deps"))
|
||||
(notify-fn (get ctx "notify")))
|
||||
(when (not (contains? dep-list s))
|
||||
(append! dep-list s)
|
||||
(signal-add-sub! s notify-fn))))
|
||||
(signal-value s)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. reset! — write a new value, notify subscribers
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define reset! :effects [mutation]
|
||||
(fn ((s :as signal) value)
|
||||
(when (signal? s)
|
||||
(let ((old (signal-value s)))
|
||||
(when (not (identical? old value))
|
||||
(signal-set-value! s value)
|
||||
(notify-subscribers s))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. swap! — update signal via function
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define swap! :effects [mutation]
|
||||
(fn ((s :as signal) (f :as lambda) &rest args)
|
||||
(when (signal? s)
|
||||
(let ((old (signal-value s))
|
||||
(new-val (apply f (cons old args))))
|
||||
(when (not (identical? old new-val))
|
||||
(signal-set-value! s new-val)
|
||||
(notify-subscribers s))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. computed — derived signal with automatic dependency tracking
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; A computed signal wraps a zero-arg function. It re-evaluates when any
|
||||
;; of its dependencies change. The dependency set is discovered automatically
|
||||
;; by tracking deref calls during evaluation.
|
||||
|
||||
(define computed :effects [mutation]
|
||||
(fn ((compute-fn :as lambda))
|
||||
(let ((s (make-signal nil))
|
||||
(deps (list))
|
||||
(compute-ctx nil))
|
||||
|
||||
;; The notify function — called when a dependency changes
|
||||
(let ((recompute
|
||||
(fn ()
|
||||
;; Unsubscribe from old deps
|
||||
(for-each
|
||||
(fn ((dep :as signal)) (signal-remove-sub! dep recompute))
|
||||
(signal-deps s))
|
||||
(signal-set-deps! s (list))
|
||||
|
||||
;; Push scope-based tracking context for this computed
|
||||
(let ((ctx (dict "deps" (list) "notify" recompute)))
|
||||
(scope-push! "sx-reactive" ctx)
|
||||
(let ((new-val (cek-call compute-fn nil)))
|
||||
(scope-pop! "sx-reactive")
|
||||
;; Save discovered deps
|
||||
(signal-set-deps! s (get ctx "deps"))
|
||||
;; Update value + notify downstream
|
||||
(let ((old (signal-value s)))
|
||||
(signal-set-value! s new-val)
|
||||
(when (not (identical? old new-val))
|
||||
(notify-subscribers s))))))))
|
||||
|
||||
;; Initial computation
|
||||
(recompute)
|
||||
;; Auto-register disposal with island scope
|
||||
(register-in-scope (fn () (dispose-computed s)))
|
||||
s))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. effect — side effect that runs when dependencies change
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Like computed, but doesn't produce a signal value. Returns a dispose
|
||||
;; function that tears down the effect.
|
||||
|
||||
(define effect :effects [mutation]
|
||||
(fn ((effect-fn :as lambda))
|
||||
(let ((deps (list))
|
||||
(disposed false)
|
||||
(cleanup-fn nil))
|
||||
|
||||
(let ((run-effect
|
||||
(fn ()
|
||||
(when (not disposed)
|
||||
;; Run previous cleanup if any
|
||||
(when cleanup-fn (cek-call cleanup-fn nil))
|
||||
|
||||
;; Unsubscribe from old deps
|
||||
(for-each
|
||||
(fn ((dep :as signal)) (signal-remove-sub! dep run-effect))
|
||||
deps)
|
||||
(set! deps (list))
|
||||
|
||||
;; Push scope-based tracking context
|
||||
(let ((ctx (dict "deps" (list) "notify" run-effect)))
|
||||
(scope-push! "sx-reactive" ctx)
|
||||
(let ((result (cek-call effect-fn nil)))
|
||||
(scope-pop! "sx-reactive")
|
||||
(set! deps (get ctx "deps"))
|
||||
;; If effect returns a function, it's the cleanup
|
||||
(when (callable? result)
|
||||
(set! cleanup-fn result))))))))
|
||||
|
||||
;; Initial run
|
||||
(run-effect)
|
||||
|
||||
;; Return dispose function
|
||||
(let ((dispose-fn
|
||||
(fn ()
|
||||
(set! disposed true)
|
||||
(when cleanup-fn (cek-call cleanup-fn nil))
|
||||
(for-each
|
||||
(fn ((dep :as signal)) (signal-remove-sub! dep run-effect))
|
||||
deps)
|
||||
(set! deps (list)))))
|
||||
;; Auto-register with island scope so disposal happens on swap
|
||||
(register-in-scope dispose-fn)
|
||||
dispose-fn)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. batch — group multiple signal writes into one notification pass
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; During a batch, signal writes are deferred. Subscribers are notified
|
||||
;; once at the end, after all values have been updated.
|
||||
|
||||
(define *batch-depth* 0)
|
||||
(define *batch-queue* (list))
|
||||
|
||||
(define batch :effects [mutation]
|
||||
(fn ((thunk :as lambda))
|
||||
(set! *batch-depth* (+ *batch-depth* 1))
|
||||
(cek-call thunk nil)
|
||||
(set! *batch-depth* (- *batch-depth* 1))
|
||||
(when (= *batch-depth* 0)
|
||||
(let ((queue *batch-queue*))
|
||||
(set! *batch-queue* (list))
|
||||
;; Collect unique subscribers across all queued signals,
|
||||
;; then notify each exactly once.
|
||||
(let ((seen (list))
|
||||
(pending (list)))
|
||||
(for-each
|
||||
(fn ((s :as signal))
|
||||
(for-each
|
||||
(fn ((sub :as lambda))
|
||||
(when (not (contains? seen sub))
|
||||
(append! seen sub)
|
||||
(append! pending sub)))
|
||||
(signal-subscribers s)))
|
||||
queue)
|
||||
(for-each (fn ((sub :as lambda)) (sub)) pending))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. notify-subscribers — internal notification dispatch
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; If inside a batch, queues the signal. Otherwise, notifies immediately.
|
||||
|
||||
(define notify-subscribers :effects [mutation]
|
||||
(fn ((s :as signal))
|
||||
(if (> *batch-depth* 0)
|
||||
(when (not (contains? *batch-queue* s))
|
||||
(append! *batch-queue* s))
|
||||
(flush-subscribers s))))
|
||||
|
||||
(define flush-subscribers :effects [mutation]
|
||||
(fn ((s :as signal))
|
||||
(for-each
|
||||
(fn ((sub :as lambda)) (sub))
|
||||
(signal-subscribers s))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. Reactive tracking context
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Tracking is now scope-based. computed/effect push a dict
|
||||
;; {:deps (list) :notify fn} onto the "sx-reactive" scope stack via
|
||||
;; scope-push!/scope-pop!. deref reads it via (context "sx-reactive" nil).
|
||||
;; No platform primitives needed — uses the existing scope infrastructure.
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 10. dispose — tear down a computed signal
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; For computed signals, unsubscribe from all dependencies.
|
||||
;; For effects, the dispose function is returned by effect itself.
|
||||
|
||||
(define dispose-computed :effects [mutation]
|
||||
(fn ((s :as signal))
|
||||
(when (signal? s)
|
||||
(for-each
|
||||
(fn ((dep :as signal)) (signal-remove-sub! dep nil))
|
||||
(signal-deps s))
|
||||
(signal-set-deps! s (list)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 11. Island scope — automatic cleanup of signals within an island
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; When an island is created, all signals, effects, and computeds created
|
||||
;; within it are tracked. When the island is removed from the DOM, they
|
||||
;; are all disposed.
|
||||
;;
|
||||
;; Uses "sx-island-scope" scope name. The scope value is a collector
|
||||
;; function (fn (disposable) ...) that appends to the island's disposer list.
|
||||
|
||||
(define with-island-scope :effects [mutation]
|
||||
(fn ((scope-fn :as lambda) (body-fn :as lambda))
|
||||
(scope-push! "sx-island-scope" scope-fn)
|
||||
(let ((result (body-fn)))
|
||||
(scope-pop! "sx-island-scope")
|
||||
result)))
|
||||
|
||||
;; Hook into signal/effect/computed creation for scope tracking.
|
||||
|
||||
(define register-in-scope :effects [mutation]
|
||||
(fn ((disposable :as lambda))
|
||||
(let ((collector (context "sx-island-scope" nil)))
|
||||
(when collector
|
||||
(cek-call collector (list disposable))))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 12. Marsh scopes — child scopes within islands
|
||||
;; ==========================================================================
|
||||
;;
|
||||
;; Marshes are zones inside islands where server content is re-evaluated
|
||||
;; in the island's reactive context. When a marsh is re-morphed with new
|
||||
;; content, its old effects and computeds must be disposed WITHOUT disturbing
|
||||
;; the island's own reactive graph.
|
||||
;;
|
||||
;; Scope hierarchy: island → marsh → effects/computeds
|
||||
;; Disposing a marsh disposes its subscope. Disposing an island disposes
|
||||
;; all its marshes. The signal graph is a tree, not a flat list.
|
||||
;;
|
||||
;; Platform interface required:
|
||||
;; (dom-set-data el key val) → void — store JS value on element
|
||||
;; (dom-get-data el key) → any — retrieve stored value
|
||||
|
||||
(define with-marsh-scope :effects [mutation io]
|
||||
(fn (marsh-el (body-fn :as lambda))
|
||||
;; Execute body-fn collecting all disposables into a marsh-local list.
|
||||
;; Nested under the current island scope — if the island is disposed,
|
||||
;; the marsh is disposed too (because island scope collected the marsh's
|
||||
;; own dispose function).
|
||||
(let ((disposers (list)))
|
||||
(with-island-scope
|
||||
(fn (d) (append! disposers d))
|
||||
body-fn)
|
||||
;; Store disposers on the marsh element for later cleanup
|
||||
(dom-set-data marsh-el "sx-marsh-disposers" disposers))))
|
||||
|
||||
(define dispose-marsh-scope :effects [mutation io]
|
||||
(fn (marsh-el)
|
||||
;; Dispose all effects/computeds registered in this marsh's scope.
|
||||
;; Parent island scope and sibling marshes are unaffected.
|
||||
(let ((disposers (dom-get-data marsh-el "sx-marsh-disposers")))
|
||||
(when disposers
|
||||
(for-each (fn ((d :as lambda)) (cek-call d nil)) disposers)
|
||||
(dom-set-data marsh-el "sx-marsh-disposers" nil)))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 13. Named stores — page-level signal containers (L3)
|
||||
;; ==========================================================================
|
||||
;;
|
||||
;; Stores persist across island creation/destruction. They live at page
|
||||
;; scope, not island scope. When an island is swapped out and re-created,
|
||||
;; it reconnects to the same store instance.
|
||||
;;
|
||||
;; The store registry is global page-level state. It survives island
|
||||
;; disposal but is cleared on full page navigation.
|
||||
|
||||
(define *store-registry* (dict))
|
||||
|
||||
(define def-store :effects [mutation]
|
||||
(fn ((name :as string) (init-fn :as lambda))
|
||||
(let ((registry *store-registry*))
|
||||
;; Only create the store once — subsequent calls return existing
|
||||
(when (not (has-key? registry name))
|
||||
(set! *store-registry* (assoc registry name (cek-call init-fn nil))))
|
||||
(get *store-registry* name))))
|
||||
|
||||
(define use-store :effects []
|
||||
(fn ((name :as string))
|
||||
(if (has-key? *store-registry* name)
|
||||
(get *store-registry* name)
|
||||
(error (str "Store not found: " name
|
||||
". Call (def-store ...) before (use-store ...).")))))
|
||||
|
||||
(define clear-stores :effects [mutation]
|
||||
(fn ()
|
||||
(set! *store-registry* (dict))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 13. Event bridge — DOM event communication for lake→island
|
||||
;; ==========================================================================
|
||||
;;
|
||||
;; Server-rendered content ("htmx lakes") inside reactive islands can
|
||||
;; communicate with island signals via DOM custom events. The bridge
|
||||
;; pattern:
|
||||
;;
|
||||
;; 1. Server renders a button/link with data-sx-emit="event-name"
|
||||
;; 2. When clicked, the client dispatches a CustomEvent on the element
|
||||
;; 3. The event bubbles up to the island container
|
||||
;; 4. An island effect listens for the event and updates signals
|
||||
;;
|
||||
;; This keeps server content pure HTML — no signal references needed.
|
||||
;; The island effect is the only reactive code.
|
||||
;;
|
||||
;; Platform interface required:
|
||||
;; (dom-listen el event-name handler) → remove-fn
|
||||
;; (dom-dispatch el event-name detail) → void
|
||||
;; (event-detail e) → any
|
||||
;;
|
||||
;; These are platform primitives because they require browser DOM APIs.
|
||||
|
||||
(define emit-event :effects [io]
|
||||
(fn (el (event-name :as string) detail)
|
||||
(dom-dispatch el event-name detail)))
|
||||
|
||||
(define on-event :effects [io]
|
||||
(fn (el (event-name :as string) (handler :as lambda))
|
||||
(dom-listen el event-name handler)))
|
||||
|
||||
;; Convenience: create an effect that listens for a DOM event on an
|
||||
;; element and writes the event detail (or a transformed value) into
|
||||
;; a target signal. Returns the effect's dispose function.
|
||||
;; When the effect is disposed (island teardown), the listener is
|
||||
;; removed automatically via the cleanup return.
|
||||
|
||||
(define bridge-event :effects [mutation io]
|
||||
(fn (el (event-name :as string) (target-signal :as signal) transform-fn)
|
||||
(effect (fn ()
|
||||
(let ((remove (dom-listen el event-name
|
||||
(fn (e)
|
||||
(let ((detail (event-detail e))
|
||||
(new-val (if transform-fn
|
||||
(cek-call transform-fn (list detail))
|
||||
detail)))
|
||||
(reset! target-signal new-val))))))
|
||||
;; Return cleanup — removes listener on dispose/re-run
|
||||
remove)))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 14. Resource — async signal with loading/resolved/error states
|
||||
;; ==========================================================================
|
||||
;;
|
||||
;; A resource wraps an async operation (fetch, computation) and exposes
|
||||
;; its state as a signal. The signal transitions through:
|
||||
;; {:loading true :data nil :error nil} — initial/loading
|
||||
;; {:loading false :data result :error nil} — success
|
||||
;; {:loading false :data nil :error err} — failure
|
||||
;;
|
||||
;; Usage:
|
||||
;; (let ((user (resource (fn () (fetch-json "/api/user")))))
|
||||
;; (cond
|
||||
;; (get (deref user) "loading") (div "Loading...")
|
||||
;; (get (deref user) "error") (div "Error: " (get (deref user) "error"))
|
||||
;; :else (div (get (deref user) "data"))))
|
||||
;;
|
||||
;; Platform interface required:
|
||||
;; (promise-then promise on-resolve on-reject) → void
|
||||
|
||||
(define resource :effects [mutation io]
|
||||
(fn ((fetch-fn :as lambda))
|
||||
(let ((state (signal (dict "loading" true "data" nil "error" nil))))
|
||||
;; Kick off the async operation
|
||||
(promise-then (cek-call fetch-fn nil)
|
||||
(fn (data) (reset! state (dict "loading" false "data" data "error" nil)))
|
||||
(fn (err) (reset! state (dict "loading" false "data" nil "error" err))))
|
||||
state)))
|
||||
|
||||
|
||||
346
web/test-aser.sx
Normal file
346
web/test-aser.sx
Normal file
@@ -0,0 +1,346 @@
|
||||
;; ==========================================================================
|
||||
;; test-aser.sx — Tests for the SX wire format (aser) adapter
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: adapter-sx.sx (aser, aser-call, aser-fragment, aser-special)
|
||||
;;
|
||||
;; Platform functions required (beyond test framework):
|
||||
;; render-sx (sx-source) -> SX wire format string
|
||||
;; Parses the sx-source string, evaluates via aser in a
|
||||
;; fresh env, and returns the resulting SX wire format string.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Basic serialization
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-basics"
|
||||
(deftest "number literal passes through"
|
||||
(assert-equal "42"
|
||||
(render-sx "42")))
|
||||
|
||||
(deftest "string literal passes through"
|
||||
;; aser returns the raw string value; render-sx concatenates it directly
|
||||
(assert-equal "hello"
|
||||
(render-sx "\"hello\"")))
|
||||
|
||||
(deftest "boolean true passes through"
|
||||
(assert-equal "true"
|
||||
(render-sx "true")))
|
||||
|
||||
(deftest "boolean false passes through"
|
||||
(assert-equal "false"
|
||||
(render-sx "false")))
|
||||
|
||||
(deftest "nil produces empty"
|
||||
(assert-equal ""
|
||||
(render-sx "nil"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; HTML tag serialization
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-tags"
|
||||
(deftest "simple div"
|
||||
(assert-equal "(div \"hello\")"
|
||||
(render-sx "(div \"hello\")")))
|
||||
|
||||
(deftest "nested tags"
|
||||
(assert-equal "(div (span \"hi\"))"
|
||||
(render-sx "(div (span \"hi\"))")))
|
||||
|
||||
(deftest "multiple children"
|
||||
(assert-equal "(div (p \"a\") (p \"b\"))"
|
||||
(render-sx "(div (p \"a\") (p \"b\"))")))
|
||||
|
||||
(deftest "attributes serialize"
|
||||
(assert-equal "(div :class \"foo\" \"bar\")"
|
||||
(render-sx "(div :class \"foo\" \"bar\")")))
|
||||
|
||||
(deftest "multiple attributes"
|
||||
(assert-equal "(a :href \"/home\" :class \"link\" \"Home\")"
|
||||
(render-sx "(a :href \"/home\" :class \"link\" \"Home\")")))
|
||||
|
||||
(deftest "void elements"
|
||||
(assert-equal "(br)"
|
||||
(render-sx "(br)")))
|
||||
|
||||
(deftest "void element with attrs"
|
||||
(assert-equal "(img :src \"pic.jpg\")"
|
||||
(render-sx "(img :src \"pic.jpg\")"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Fragment serialization
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-fragments"
|
||||
(deftest "simple fragment"
|
||||
(assert-equal "(<> (p \"a\") (p \"b\"))"
|
||||
(render-sx "(<> (p \"a\") (p \"b\"))")))
|
||||
|
||||
(deftest "empty fragment"
|
||||
(assert-equal ""
|
||||
(render-sx "(<>)")))
|
||||
|
||||
(deftest "single-child fragment"
|
||||
(assert-equal "(<> (div \"x\"))"
|
||||
(render-sx "(<> (div \"x\"))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Control flow in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-control-flow"
|
||||
(deftest "if true branch"
|
||||
(assert-equal "(p \"yes\")"
|
||||
(render-sx "(if true (p \"yes\") (p \"no\"))")))
|
||||
|
||||
(deftest "if false branch"
|
||||
(assert-equal "(p \"no\")"
|
||||
(render-sx "(if false (p \"yes\") (p \"no\"))")))
|
||||
|
||||
(deftest "when true"
|
||||
(assert-equal "(p \"ok\")"
|
||||
(render-sx "(when true (p \"ok\"))")))
|
||||
|
||||
(deftest "when false"
|
||||
(assert-equal ""
|
||||
(render-sx "(when false (p \"ok\"))")))
|
||||
|
||||
(deftest "cond serializes matching branch"
|
||||
(assert-equal "(p \"two\")"
|
||||
(render-sx "(cond false (p \"one\") true (p \"two\") :else (p \"three\"))")))
|
||||
|
||||
(deftest "cond with 2-element predicate test"
|
||||
;; Regression: cond misclassifies (nil? x) as scheme-style clause.
|
||||
(assert-equal "(p \"yes\")"
|
||||
(render-sx "(cond (nil? nil) (p \"yes\") :else (p \"no\"))"))
|
||||
(assert-equal "(p \"no\")"
|
||||
(render-sx "(cond (nil? \"x\") (p \"yes\") :else (p \"no\"))")))
|
||||
|
||||
(deftest "let binds then serializes"
|
||||
(assert-equal "(p \"hello\")"
|
||||
(render-sx "(let ((x \"hello\")) (p x))")))
|
||||
|
||||
(deftest "let preserves outer scope bindings"
|
||||
;; Regression: process-bindings must preserve parent env scope chain.
|
||||
;; Using merge() instead of env-extend loses parent scope items.
|
||||
(assert-equal "(p \"outer\")"
|
||||
(render-sx "(do (define theme \"outer\") (let ((x 1)) (p theme)))")))
|
||||
|
||||
(deftest "nested let preserves outer scope"
|
||||
(assert-equal "(div (span \"hello\") (span \"world\"))"
|
||||
(render-sx "(do (define a \"hello\")
|
||||
(define b \"world\")
|
||||
(div (let ((x 1)) (span a))
|
||||
(let ((y 2)) (span b))))")))
|
||||
|
||||
(deftest "begin serializes last"
|
||||
(assert-equal "(p \"last\")"
|
||||
(render-sx "(begin (p \"first\") (p \"last\"))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; THE BUG — map/filter list flattening in children (critical regression)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-list-flattening"
|
||||
(deftest "map inside tag flattens children"
|
||||
(assert-equal "(div (span \"a\") (span \"b\") (span \"c\"))"
|
||||
(render-sx "(do (define items (list \"a\" \"b\" \"c\"))
|
||||
(div (map (fn (x) (span x)) items)))")))
|
||||
|
||||
(deftest "map inside tag with other children"
|
||||
(assert-equal "(ul (li \"first\") (li \"a\") (li \"b\"))"
|
||||
(render-sx "(do (define items (list \"a\" \"b\"))
|
||||
(ul (li \"first\") (map (fn (x) (li x)) items)))")))
|
||||
|
||||
(deftest "filter result via let binding as children"
|
||||
;; Note: (filter ...) is treated as an SVG tag in aser dispatch (SVG has <filter>),
|
||||
;; so we evaluate filter via let binding + map to serialize children
|
||||
(assert-equal "(ul (li \"a\") (li \"b\"))"
|
||||
(render-sx "(do (define items (list \"a\" nil \"b\"))
|
||||
(define kept (filter (fn (x) (not (nil? x))) items))
|
||||
(ul (map (fn (x) (li x)) kept)))")))
|
||||
|
||||
(deftest "map inside fragment flattens"
|
||||
(assert-equal "(<> (p \"a\") (p \"b\"))"
|
||||
(render-sx "(do (define items (list \"a\" \"b\"))
|
||||
(<> (map (fn (x) (p x)) items)))")))
|
||||
|
||||
(deftest "nested map does not double-wrap"
|
||||
(assert-equal "(div (span \"1\") (span \"2\"))"
|
||||
(render-sx "(do (define nums (list 1 2))
|
||||
(div (map (fn (n) (span (str n))) nums)))")))
|
||||
|
||||
(deftest "map with component-like output flattens"
|
||||
(assert-equal "(div (li \"x\") (li \"y\"))"
|
||||
(render-sx "(do (define items (list \"x\" \"y\"))
|
||||
(div (map (fn (x) (li x)) items)))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Component serialization (NOT expanded in basic aser mode)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-components"
|
||||
(deftest "unknown component serializes as-is"
|
||||
(assert-equal "(~foo :title \"bar\")"
|
||||
(render-sx "(~foo :title \"bar\")")))
|
||||
|
||||
(deftest "defcomp then unexpanded component call"
|
||||
(assert-equal "(~card :title \"Hi\")"
|
||||
(render-sx "(do (defcomp ~card (&key title) (h1 title)) (~card :title \"Hi\"))")))
|
||||
|
||||
(deftest "component with children serializes unexpanded"
|
||||
(assert-equal "(~box (p \"inside\"))"
|
||||
(render-sx "(do (defcomp ~box (&key &rest children) (div children))
|
||||
(~box (p \"inside\")))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Definition forms in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-definitions"
|
||||
(deftest "define evaluates for side effects, returns nil"
|
||||
(assert-equal "(p 42)"
|
||||
(render-sx "(do (define x 42) (p x))")))
|
||||
|
||||
(deftest "defcomp evaluates and returns nil"
|
||||
(assert-equal "(~tag :x 1)"
|
||||
(render-sx "(do (defcomp ~tag (&key x) (span x)) (~tag :x 1))")))
|
||||
|
||||
(deftest "defisland evaluates AND serializes"
|
||||
(let ((result (render-sx "(defisland ~counter (&key count) (span count))")))
|
||||
(assert-true (string-contains? result "defisland")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Function calls in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-function-calls"
|
||||
(deftest "named function call evaluates fully"
|
||||
(assert-equal "3"
|
||||
(render-sx "(do (define inc1 (fn (x) (+ x 1))) (inc1 2))")))
|
||||
|
||||
(deftest "define + call"
|
||||
(assert-equal "10"
|
||||
(render-sx "(do (define double (fn (x) (* x 2))) (double 5))")))
|
||||
|
||||
(deftest "native callable with multiple args"
|
||||
;; Regression: async-aser-eval-call passed evaled-args list to
|
||||
;; async-invoke (&rest), wrapping it in another list. apply(f, [list])
|
||||
;; calls f(list) instead of f(*list).
|
||||
(assert-equal "3"
|
||||
(render-sx "(do (define my-add +) (my-add 1 2))")))
|
||||
|
||||
(deftest "native callable with two args via alias"
|
||||
(assert-equal "hello world"
|
||||
(render-sx "(do (define my-join str) (my-join \"hello\" \" world\"))")))
|
||||
|
||||
(deftest "higher-order: map returns list"
|
||||
(let ((result (render-sx "(map (fn (x) (+ x 1)) (list 1 2 3))")))
|
||||
;; map at top level returns a list, not serialized tags
|
||||
(assert-true (not (nil? result))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; and/or short-circuit in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-logic"
|
||||
(deftest "and short-circuits on false"
|
||||
(assert-equal "false"
|
||||
(render-sx "(and true false true)")))
|
||||
|
||||
(deftest "and returns last truthy"
|
||||
(assert-equal "3"
|
||||
(render-sx "(and 1 2 3)")))
|
||||
|
||||
(deftest "or short-circuits on true"
|
||||
(assert-equal "1"
|
||||
(render-sx "(or 1 2 3)")))
|
||||
|
||||
(deftest "or returns last falsy"
|
||||
(assert-equal "false"
|
||||
(render-sx "(or false false)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Spread serialization
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-spreads"
|
||||
(deftest "spread in element merges attrs"
|
||||
(assert-equal "(div :class \"card\" \"hello\")"
|
||||
(render-sx "(div (make-spread {:class \"card\"}) \"hello\")")))
|
||||
|
||||
(deftest "multiple spreads merge into element"
|
||||
(assert-equal "(div :class \"card\" :style \"color:red\" \"hello\")"
|
||||
(render-sx "(div (make-spread {:class \"card\"}) (make-spread {:style \"color:red\"}) \"hello\")")))
|
||||
|
||||
(deftest "spread in fragment is silently dropped"
|
||||
(assert-equal "(<> \"hello\")"
|
||||
(render-sx "(<> (make-spread {:class \"card\"}) \"hello\")")))
|
||||
|
||||
(deftest "stored spread in let binding"
|
||||
(assert-equal "(div :class \"card\" \"hello\")"
|
||||
(render-sx "(let ((card (make-spread {:class \"card\"})))
|
||||
(div card \"hello\"))")))
|
||||
|
||||
(deftest "spread in nested element"
|
||||
(assert-equal "(div (span :class \"inner\" \"hi\"))"
|
||||
(render-sx "(div (span (make-spread {:class \"inner\"}) \"hi\"))")))
|
||||
|
||||
(deftest "spread in non-element context silently drops"
|
||||
(assert-equal "hello"
|
||||
(render-sx "(do (make-spread {:class \"card\"}) \"hello\")"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Scope tests — unified scope primitive
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scope"
|
||||
|
||||
(deftest "scope with value and context"
|
||||
(assert-equal "dark"
|
||||
(render-sx "(scope \"sc-theme\" :value \"dark\" (context \"sc-theme\"))")))
|
||||
|
||||
(deftest "scope without value defaults to nil"
|
||||
(assert-equal ""
|
||||
(render-sx "(scope \"sc-nil\" (str (context \"sc-nil\")))")))
|
||||
|
||||
(deftest "scope with emit!/emitted"
|
||||
(assert-equal "a,b"
|
||||
(render-sx "(scope \"sc-emit\" (emit! \"sc-emit\" \"a\") (emit! \"sc-emit\" \"b\") (join \",\" (emitted \"sc-emit\")))")))
|
||||
|
||||
(deftest "provide is equivalent to scope with value"
|
||||
(assert-equal "42"
|
||||
(render-sx "(provide \"sc-prov\" 42 (str (context \"sc-prov\")))")))
|
||||
|
||||
(deftest "collect! works via scope (lazy root scope)"
|
||||
(assert-equal "x,y"
|
||||
(render-sx "(do (collect! \"sc-coll\" \"x\") (collect! \"sc-coll\" \"y\") (join \",\" (collected \"sc-coll\")))")))
|
||||
|
||||
(deftest "collect! deduplicates"
|
||||
(assert-equal "a"
|
||||
(render-sx "(do (collect! \"sc-dedup\" \"a\") (collect! \"sc-dedup\" \"a\") (join \",\" (collected \"sc-dedup\")))")))
|
||||
|
||||
(deftest "clear-collected! clears scope accumulator"
|
||||
(assert-equal ""
|
||||
(render-sx "(do (collect! \"sc-clear\" \"x\") (clear-collected! \"sc-clear\") (join \",\" (collected \"sc-clear\")))")))
|
||||
|
||||
(deftest "nested scope shadows outer"
|
||||
(assert-equal "inner"
|
||||
(render-sx "(scope \"sc-nest\" :value \"outer\" (scope \"sc-nest\" :value \"inner\" (context \"sc-nest\")))")))
|
||||
|
||||
(deftest "scope pops correctly after body"
|
||||
(assert-equal "outer"
|
||||
(render-sx "(scope \"sc-pop\" :value \"outer\" (scope \"sc-pop\" :value \"inner\" \"ignore\") (context \"sc-pop\"))"))))
|
||||
279
web/test-cek-reactive.sx
Normal file
279
web/test-cek-reactive.sx
Normal file
@@ -0,0 +1,279 @@
|
||||
;; ==========================================================================
|
||||
;; test-cek-reactive.sx — Tests for deref-as-shift reactive rendering
|
||||
;;
|
||||
;; Tests that (deref signal) inside a reactive-reset boundary performs
|
||||
;; continuation capture: the rest of the expression becomes the subscriber.
|
||||
;;
|
||||
;; Requires: test-framework.sx, frames.sx, cek.sx, signals.sx loaded first.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Basic deref behavior through CEK
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "deref pass-through"
|
||||
(deftest "deref non-signal passes through"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(deref 42)")
|
||||
(test-env))))
|
||||
(assert-equal 42 result)))
|
||||
|
||||
(deftest "deref nil passes through"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(deref nil)")
|
||||
(test-env))))
|
||||
(assert-nil result)))
|
||||
|
||||
(deftest "deref string passes through"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(deref \"hello\")")
|
||||
(test-env))))
|
||||
(assert-equal "hello" result))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Deref signal without reactive-reset (no shift)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "deref signal without reactive-reset"
|
||||
(deftest "deref signal returns current value"
|
||||
(let ((s (signal 99)))
|
||||
(env-set! (test-env) "test-sig" s)
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(deref test-sig)")
|
||||
(test-env))))
|
||||
(assert-equal 99 result))))
|
||||
|
||||
(deftest "deref signal in expression returns computed value"
|
||||
(let ((s (signal 10)))
|
||||
(env-set! (test-env) "test-sig" s)
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(+ 5 (deref test-sig))")
|
||||
(test-env))))
|
||||
(assert-equal 15 result)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Reactive reset + deref: continuation capture
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "reactive-reset shift"
|
||||
(deftest "deref signal with reactive-reset captures continuation"
|
||||
(let ((s (signal 42))
|
||||
(captured-val nil))
|
||||
;; Run CEK with a ReactiveResetFrame
|
||||
(let ((result (cek-run
|
||||
(make-cek-state
|
||||
(sx-parse-one "(deref test-sig)")
|
||||
(let ((e (env-extend (test-env))))
|
||||
(env-set! e "test-sig" s)
|
||||
e)
|
||||
(list (make-reactive-reset-frame
|
||||
(test-env)
|
||||
(fn (v) (set! captured-val v))
|
||||
true))))))
|
||||
;; Initial render: returns current value, update-fn NOT called (first-render)
|
||||
(assert-equal 42 result)
|
||||
(assert-nil captured-val))))
|
||||
|
||||
(deftest "signal change invokes subscriber with update-fn"
|
||||
(let ((s (signal 10))
|
||||
(update-calls (list)))
|
||||
;; Set up reactive-reset with tracking update-fn
|
||||
(scope-push! "sx-island-scope" nil)
|
||||
(let ((e (env-extend (test-env))))
|
||||
(env-set! e "test-sig" s)
|
||||
(cek-run
|
||||
(make-cek-state
|
||||
(sx-parse-one "(deref test-sig)")
|
||||
e
|
||||
(list (make-reactive-reset-frame
|
||||
e
|
||||
(fn (v) (append! update-calls v))
|
||||
true)))))
|
||||
;; Change signal — subscriber should fire
|
||||
(reset! s 20)
|
||||
(assert-equal 1 (len update-calls))
|
||||
(assert-equal 20 (first update-calls))
|
||||
;; Change again
|
||||
(reset! s 30)
|
||||
(assert-equal 2 (len update-calls))
|
||||
(assert-equal 30 (nth update-calls 1))
|
||||
(scope-pop! "sx-island-scope")))
|
||||
|
||||
(deftest "expression with deref captures rest as continuation"
|
||||
(let ((s (signal 5))
|
||||
(update-calls (list)))
|
||||
(scope-push! "sx-island-scope" nil)
|
||||
(let ((e (env-extend (test-env))))
|
||||
(env-set! e "test-sig" s)
|
||||
;; (str "val=" (deref test-sig)) — continuation captures (str "val=" [HOLE])
|
||||
(let ((result (cek-run
|
||||
(make-cek-state
|
||||
(sx-parse-one "(str \"val=\" (deref test-sig))")
|
||||
e
|
||||
(list (make-reactive-reset-frame
|
||||
e
|
||||
(fn (v) (append! update-calls v))
|
||||
true))))))
|
||||
(assert-equal "val=5" result)))
|
||||
;; Change signal — should get updated string
|
||||
(reset! s 42)
|
||||
(assert-equal 1 (len update-calls))
|
||||
(assert-equal "val=42" (first update-calls))
|
||||
(scope-pop! "sx-island-scope"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Disposal and cleanup
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "disposal"
|
||||
(deftest "scope cleanup unsubscribes continuation"
|
||||
(let ((s (signal 1))
|
||||
(update-calls (list))
|
||||
(disposers (list)))
|
||||
;; Create island scope with collector that accumulates disposers
|
||||
(scope-push! "sx-island-scope" (fn (d) (append! disposers d)))
|
||||
(let ((e (env-extend (test-env))))
|
||||
(env-set! e "test-sig" s)
|
||||
(cek-run
|
||||
(make-cek-state
|
||||
(sx-parse-one "(deref test-sig)")
|
||||
e
|
||||
(list (make-reactive-reset-frame
|
||||
e
|
||||
(fn (v) (append! update-calls v))
|
||||
true)))))
|
||||
;; Pop scope — call all disposers
|
||||
(scope-pop! "sx-island-scope")
|
||||
(for-each (fn (d) (cek-call d nil)) disposers)
|
||||
;; Change signal — no update should fire
|
||||
(reset! s 999)
|
||||
(assert-equal 0 (len update-calls)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; cek-call integration — computed/effect use cek-call dispatch
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-call dispatch"
|
||||
(deftest "cek-call invokes native function"
|
||||
(let ((log (list)))
|
||||
(cek-call (fn (x) (append! log x)) (list 42))
|
||||
(assert-equal (list 42) log)))
|
||||
|
||||
(deftest "cek-call invokes zero-arg lambda"
|
||||
(let ((result (cek-call (fn () (+ 1 2)) nil)))
|
||||
(assert-equal 3 result)))
|
||||
|
||||
(deftest "cek-call with nil function returns nil"
|
||||
(assert-nil (cek-call nil nil)))
|
||||
|
||||
(deftest "computed tracks deps via cek-call"
|
||||
(let ((s (signal 10)))
|
||||
(let ((c (computed (fn () (* 2 (deref s))))))
|
||||
(assert-equal 20 (deref c))
|
||||
(reset! s 5)
|
||||
(assert-equal 10 (deref c)))))
|
||||
|
||||
(deftest "effect runs and re-runs via cek-call"
|
||||
(let ((s (signal "a"))
|
||||
(log (list)))
|
||||
(effect (fn () (append! log (deref s))))
|
||||
(assert-equal (list "a") log)
|
||||
(reset! s "b")
|
||||
(assert-equal (list "a" "b") log)))
|
||||
|
||||
(deftest "effect cleanup runs on re-trigger"
|
||||
(let ((s (signal 0))
|
||||
(log (list)))
|
||||
(effect (fn ()
|
||||
(let ((val (deref s)))
|
||||
(append! log (str "run:" val))
|
||||
;; Return cleanup function
|
||||
(fn () (append! log (str "clean:" val))))))
|
||||
(assert-equal (list "run:0") log)
|
||||
(reset! s 1)
|
||||
(assert-equal (list "run:0" "clean:0" "run:1") log)))
|
||||
|
||||
(deftest "batch coalesces via cek-call"
|
||||
(let ((s (signal 0))
|
||||
(count (signal 0)))
|
||||
(effect (fn () (do (deref s) (swap! count inc))))
|
||||
(assert-equal 1 (deref count))
|
||||
(batch (fn ()
|
||||
(reset! s 1)
|
||||
(reset! s 2)
|
||||
(reset! s 3)))
|
||||
;; batch should coalesce — effect runs once, not three times
|
||||
(assert-equal 2 (deref count)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; CEK-native higher-order forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "CEK higher-order forms"
|
||||
(deftest "map through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(map (fn (x) (* x 2)) (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-equal (list 2 4 6) result)))
|
||||
|
||||
(deftest "map-indexed through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(map-indexed (fn (i x) (+ i x)) (list 10 20 30))")
|
||||
(test-env))))
|
||||
(assert-equal (list 10 21 32) result)))
|
||||
|
||||
(deftest "filter through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(filter (fn (x) (> x 2)) (list 1 2 3 4 5))")
|
||||
(test-env))))
|
||||
(assert-equal (list 3 4 5) result)))
|
||||
|
||||
(deftest "reduce through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-equal 6 result)))
|
||||
|
||||
(deftest "some through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(some (fn (x) (> x 3)) (list 1 2 3 4 5))")
|
||||
(test-env))))
|
||||
(assert-true result)))
|
||||
|
||||
(deftest "some returns false when none match"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(some (fn (x) (> x 10)) (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-false result)))
|
||||
|
||||
(deftest "every? through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(every? (fn (x) (> x 0)) (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-true result)))
|
||||
|
||||
(deftest "every? returns false on first falsy"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(every? (fn (x) (> x 2)) (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-false result)))
|
||||
|
||||
(deftest "for-each through CEK"
|
||||
(let ((log (list)))
|
||||
(env-set! (test-env) "test-log" log)
|
||||
(eval-expr-cek
|
||||
(sx-parse-one "(for-each (fn (x) (append! test-log x)) (list 1 2 3))")
|
||||
(test-env))
|
||||
(assert-equal (list 1 2 3) log)))
|
||||
|
||||
(deftest "map on empty list"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(map (fn (x) x) (list))")
|
||||
(test-env))))
|
||||
(assert-equal (list) result))))
|
||||
327
web/test-deps.sx
Normal file
327
web/test-deps.sx
Normal file
@@ -0,0 +1,327 @@
|
||||
;; ==========================================================================
|
||||
;; test-deps.sx — Tests for component dependency analysis (deps.sx)
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions: scan-refs, transitive-deps, components-needed,
|
||||
;; component-pure?, scan-io-refs, transitive-io-refs,
|
||||
;; scan-components-from-source, test-env
|
||||
;; (loaded from bootstrapped output by test runners)
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Test component definitions — these exist in the test env for dep analysis
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~dep-leaf ()
|
||||
(span "leaf"))
|
||||
|
||||
(defcomp ~dep-branch ()
|
||||
(div (~dep-leaf)))
|
||||
|
||||
(defcomp ~dep-trunk ()
|
||||
(div (~dep-branch) (~dep-leaf)))
|
||||
|
||||
(defcomp ~dep-conditional (&key show?)
|
||||
(if show?
|
||||
(~dep-leaf)
|
||||
(~dep-branch)))
|
||||
|
||||
(defcomp ~dep-nested-cond (&key mode)
|
||||
(cond
|
||||
(= mode "a") (~dep-leaf)
|
||||
(= mode "b") (~dep-branch)
|
||||
:else (~dep-trunk)))
|
||||
|
||||
(defcomp ~dep-island ()
|
||||
(div "no deps"))
|
||||
|
||||
;; Islands with dependencies — defisland bodies must be scanned
|
||||
(defisland ~dep-island-with-child ()
|
||||
(div (~dep-leaf) "island content"))
|
||||
|
||||
(defisland ~dep-island-with-chain ()
|
||||
(div (~dep-branch) "deep island"))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. scan-refs — finds component references in AST nodes
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scan-refs"
|
||||
|
||||
(deftest "empty for string literal"
|
||||
(assert-equal (list) (scan-refs "hello")))
|
||||
|
||||
(deftest "empty for number"
|
||||
(assert-equal (list) (scan-refs 42)))
|
||||
|
||||
(deftest "finds component symbol"
|
||||
(let ((refs (scan-refs (quote (~dep-leaf)))))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds in nested list"
|
||||
(let ((refs (scan-refs (quote (div (span (~dep-leaf)))))))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds multiple refs"
|
||||
(let ((refs (scan-refs (quote (div (~dep-leaf) (~dep-branch))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "deduplicates"
|
||||
(let ((refs (scan-refs (quote (div (~dep-leaf) (~dep-leaf))))))
|
||||
(assert-equal 1 (len refs))))
|
||||
|
||||
(deftest "walks if branches"
|
||||
(let ((refs (scan-refs (quote (if true (~dep-leaf) (~dep-branch))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "walks cond branches"
|
||||
(let ((refs (scan-refs (quote (cond (= x 1) (~dep-leaf) :else (~dep-trunk))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-trunk" refs)))
|
||||
|
||||
(deftest "ignores non-component symbols"
|
||||
(let ((refs (scan-refs (quote (div class "foo")))))
|
||||
(assert-equal 0 (len refs)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. scan-components-from-source — regex-based source string scanning
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scan-components-from-source"
|
||||
|
||||
(deftest "finds single component"
|
||||
(let ((refs (scan-components-from-source "(~dep-leaf)")))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds multiple components"
|
||||
(let ((refs (scan-components-from-source "(div (~dep-leaf) (~dep-branch))")))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "no false positives on plain text"
|
||||
(let ((refs (scan-components-from-source "(div \"hello world\")")))
|
||||
(assert-equal 0 (len refs))))
|
||||
|
||||
(deftest "handles hyphenated names"
|
||||
(let ((refs (scan-components-from-source "(~my-component :key val)")))
|
||||
(assert-contains "~my-component" refs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. transitive-deps — transitive dependency closure
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "transitive-deps"
|
||||
|
||||
(deftest "leaf has no deps"
|
||||
(let ((deps (transitive-deps "~dep-leaf" (test-env))))
|
||||
(assert-equal 0 (len deps))))
|
||||
|
||||
(deftest "direct dependency"
|
||||
(let ((deps (transitive-deps "~dep-branch" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "transitive closure"
|
||||
(let ((deps (transitive-deps "~dep-trunk" (test-env))))
|
||||
(assert-contains "~dep-branch" deps)
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "excludes self"
|
||||
(let ((deps (transitive-deps "~dep-trunk" (test-env))))
|
||||
(assert-false (contains? deps "~dep-trunk"))))
|
||||
|
||||
(deftest "walks conditional branches"
|
||||
(let ((deps (transitive-deps "~dep-conditional" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)
|
||||
(assert-contains "~dep-branch" deps)))
|
||||
|
||||
(deftest "walks all cond branches"
|
||||
(let ((deps (transitive-deps "~dep-nested-cond" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)
|
||||
(assert-contains "~dep-branch" deps)
|
||||
(assert-contains "~dep-trunk" deps)))
|
||||
|
||||
(deftest "island has no deps"
|
||||
(let ((deps (transitive-deps "~dep-island" (test-env))))
|
||||
(assert-equal 0 (len deps))))
|
||||
|
||||
(deftest "accepts name without tilde"
|
||||
(let ((deps (transitive-deps "dep-branch" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "island direct dep scanned"
|
||||
(let ((deps (transitive-deps "~dep-island-with-child" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "island transitive deps scanned"
|
||||
(let ((deps (transitive-deps "~dep-island-with-chain" (test-env))))
|
||||
(assert-contains "~dep-branch" deps)
|
||||
(assert-contains "~dep-leaf" deps))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. components-needed — page bundle computation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "components-needed"
|
||||
|
||||
(deftest "finds direct and transitive"
|
||||
(let ((needed (components-needed "(~dep-trunk)" (test-env))))
|
||||
(assert-contains "~dep-trunk" needed)
|
||||
(assert-contains "~dep-branch" needed)
|
||||
(assert-contains "~dep-leaf" needed)))
|
||||
|
||||
(deftest "deduplicates"
|
||||
(let ((needed (components-needed "(div (~dep-leaf) (~dep-leaf))" (test-env))))
|
||||
;; ~dep-leaf should appear only once
|
||||
(assert-true (contains? needed "~dep-leaf"))))
|
||||
|
||||
(deftest "handles leaf page"
|
||||
(let ((needed (components-needed "(~dep-island)" (test-env))))
|
||||
(assert-contains "~dep-island" needed)
|
||||
(assert-equal 1 (len needed))))
|
||||
|
||||
(deftest "handles multiple top-level components"
|
||||
(let ((needed (components-needed "(div (~dep-leaf) (~dep-island))" (test-env))))
|
||||
(assert-contains "~dep-leaf" needed)
|
||||
(assert-contains "~dep-island" needed)))
|
||||
|
||||
(deftest "island deps included in page bundle"
|
||||
(let ((needed (components-needed "(~dep-island-with-chain)" (test-env))))
|
||||
(assert-contains "~dep-island-with-chain" needed)
|
||||
(assert-contains "~dep-branch" needed)
|
||||
(assert-contains "~dep-leaf" needed))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. IO detection — scan-io-refs, component-pure?
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Define components that reference "io" functions for testing
|
||||
(defcomp ~dep-pure ()
|
||||
(div (~dep-leaf) "static"))
|
||||
|
||||
(defcomp ~dep-io ()
|
||||
(div (fetch-data "/api")))
|
||||
|
||||
(defcomp ~dep-io-indirect ()
|
||||
(div (~dep-io)))
|
||||
|
||||
(defsuite "scan-io-refs"
|
||||
|
||||
(deftest "no IO in pure AST"
|
||||
(let ((refs (scan-io-refs (quote (div "hello" (span "world"))) (list "fetch-data"))))
|
||||
(assert-equal 0 (len refs))))
|
||||
|
||||
(deftest "finds IO reference"
|
||||
(let ((refs (scan-io-refs (quote (div (fetch-data "/api"))) (list "fetch-data"))))
|
||||
(assert-contains "fetch-data" refs)))
|
||||
|
||||
(deftest "multiple IO refs"
|
||||
(let ((refs (scan-io-refs (quote (div (fetch-data "/a") (query-db "x"))) (list "fetch-data" "query-db"))))
|
||||
(assert-contains "fetch-data" refs)
|
||||
(assert-contains "query-db" refs)))
|
||||
|
||||
(deftest "ignores non-IO symbols"
|
||||
(let ((refs (scan-io-refs (quote (div (map str items))) (list "fetch-data"))))
|
||||
(assert-equal 0 (len refs)))))
|
||||
|
||||
|
||||
(defsuite "component-pure?"
|
||||
|
||||
(deftest "pure component is pure"
|
||||
(assert-true (component-pure? "~dep-pure" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "IO component is not pure"
|
||||
(assert-false (component-pure? "~dep-io" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "indirect IO is not pure"
|
||||
(assert-false (component-pure? "~dep-io-indirect" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "leaf component is pure"
|
||||
(assert-true (component-pure? "~dep-leaf" (test-env) (list "fetch-data")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. render-target — boundary decision with affinity
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Components with explicit affinity annotations
|
||||
(defcomp ~dep-force-client (&key x)
|
||||
:affinity :client
|
||||
(div (fetch-data "/api") x))
|
||||
|
||||
(defcomp ~dep-force-server (&key x)
|
||||
:affinity :server
|
||||
(div x))
|
||||
|
||||
(defcomp ~dep-auto-pure (&key x)
|
||||
(div x))
|
||||
|
||||
(defcomp ~dep-auto-io (&key x)
|
||||
(div (fetch-data "/api")))
|
||||
|
||||
(defsuite "render-target"
|
||||
|
||||
(deftest "pure auto component targets client"
|
||||
(assert-equal "client" (render-target "~dep-auto-pure" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "IO auto component targets server"
|
||||
(assert-equal "server" (render-target "~dep-auto-io" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "affinity client overrides IO to client"
|
||||
(assert-equal "client" (render-target "~dep-force-client" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "affinity server overrides pure to server"
|
||||
(assert-equal "server" (render-target "~dep-force-server" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "leaf component targets client"
|
||||
(assert-equal "client" (render-target "~dep-leaf" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "unknown name targets server"
|
||||
(assert-equal "server" (render-target "~nonexistent" (test-env) (list "fetch-data")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. page-render-plan — per-page boundary plan
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; A page component that uses both pure and IO components
|
||||
(defcomp ~plan-page (&key data)
|
||||
(div
|
||||
(~dep-auto-pure :x "hello")
|
||||
(~dep-auto-io :x data)
|
||||
(~dep-force-client :x "interactive")))
|
||||
|
||||
(defsuite "page-render-plan"
|
||||
|
||||
(deftest "plan classifies components correctly"
|
||||
(let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data"))))
|
||||
;; ~plan-page has transitive IO deps (via ~dep-auto-io) so targets server
|
||||
(assert-equal "server" (dict-get (get plan :components) "~plan-page"))
|
||||
(assert-equal "client" (dict-get (get plan :components) "~dep-auto-pure"))
|
||||
(assert-equal "server" (dict-get (get plan :components) "~dep-auto-io"))
|
||||
(assert-equal "client" (dict-get (get plan :components) "~dep-force-client"))))
|
||||
|
||||
(deftest "plan server list contains IO components"
|
||||
(let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data"))))
|
||||
(assert-true (contains? (get plan :server) "~dep-auto-io"))))
|
||||
|
||||
(deftest "plan client list contains pure components"
|
||||
(let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data"))))
|
||||
(assert-true (contains? (get plan :client) "~dep-auto-pure"))
|
||||
(assert-true (contains? (get plan :client) "~dep-force-client"))))
|
||||
|
||||
(deftest "plan collects IO deps from server components"
|
||||
(let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data"))))
|
||||
(assert-true (contains? (get plan :io-deps) "fetch-data"))))
|
||||
|
||||
(deftest "pure-only page has empty server list"
|
||||
(let ((plan (page-render-plan "(~dep-auto-pure :x 1)" (test-env) (list "fetch-data"))))
|
||||
(assert-equal 0 (len (get plan :server)))
|
||||
(assert-true (> (len (get plan :client)) 0)))))
|
||||
212
web/test-engine.sx
Normal file
212
web/test-engine.sx
Normal file
@@ -0,0 +1,212 @@
|
||||
;; ==========================================================================
|
||||
;; test-engine.sx — Tests for SxEngine pure logic (engine.sx)
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions: parse-time, parse-trigger-spec, default-trigger,
|
||||
;; parse-swap-spec, parse-retry-spec, next-retry-ms, filter-params
|
||||
;; (loaded from bootstrapped output by test runners)
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. parse-time — time string parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-time"
|
||||
|
||||
(deftest "seconds to ms"
|
||||
(assert-equal 2000 (parse-time "2s")))
|
||||
|
||||
(deftest "milliseconds"
|
||||
(assert-equal 500 (parse-time "500ms")))
|
||||
|
||||
(deftest "nil returns 0"
|
||||
(assert-equal 0 (parse-time nil)))
|
||||
|
||||
(deftest "plain number string"
|
||||
(assert-equal 100 (parse-time "100")))
|
||||
|
||||
(deftest "one second"
|
||||
(assert-equal 1000 (parse-time "1s")))
|
||||
|
||||
(deftest "large seconds"
|
||||
(assert-equal 30000 (parse-time "30s"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. parse-trigger-spec — trigger attribute parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-trigger-spec"
|
||||
|
||||
(deftest "nil returns nil"
|
||||
(assert-nil (parse-trigger-spec nil)))
|
||||
|
||||
(deftest "single event"
|
||||
(let ((triggers (parse-trigger-spec "click")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "event with once modifier"
|
||||
(let ((triggers (parse-trigger-spec "click once")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))
|
||||
(assert-true (get (get (first triggers) "modifiers") "once"))))
|
||||
|
||||
(deftest "event with delay modifier"
|
||||
(let ((triggers (parse-trigger-spec "click delay:500ms")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal 500 (get (get (first triggers) "modifiers") "delay"))))
|
||||
|
||||
(deftest "multiple triggers comma-separated"
|
||||
(let ((triggers (parse-trigger-spec "click,change")))
|
||||
(assert-equal 2 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))
|
||||
(assert-equal "change" (get (nth triggers 1) "event"))))
|
||||
|
||||
(deftest "polling trigger"
|
||||
(let ((triggers (parse-trigger-spec "every 3s")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "every" (get (first triggers) "event"))
|
||||
(assert-equal 3000 (get (get (first triggers) "modifiers") "interval"))))
|
||||
|
||||
(deftest "event with from modifier"
|
||||
(let ((triggers (parse-trigger-spec "click from:body")))
|
||||
(assert-equal "body" (get (get (first triggers) "modifiers") "from"))))
|
||||
|
||||
(deftest "event with changed modifier"
|
||||
(let ((triggers (parse-trigger-spec "keyup changed")))
|
||||
(assert-equal "keyup" (get (first triggers) "event"))
|
||||
(assert-true (get (get (first triggers) "modifiers") "changed")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. default-trigger — default trigger by element tag
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "default-trigger"
|
||||
|
||||
(deftest "form submits"
|
||||
(let ((triggers (default-trigger "FORM")))
|
||||
(assert-equal "submit" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "input changes"
|
||||
(let ((triggers (default-trigger "INPUT")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "select changes"
|
||||
(let ((triggers (default-trigger "SELECT")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "textarea changes"
|
||||
(let ((triggers (default-trigger "TEXTAREA")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "div clicks"
|
||||
(let ((triggers (default-trigger "DIV")))
|
||||
(assert-equal "click" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "button clicks"
|
||||
(let ((triggers (default-trigger "BUTTON")))
|
||||
(assert-equal "click" (get (first triggers) "event")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. parse-swap-spec — swap specification parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-swap-spec"
|
||||
|
||||
(deftest "default swap"
|
||||
(let ((spec (parse-swap-spec nil false)))
|
||||
(assert-equal "outerHTML" (get spec "style"))
|
||||
(assert-false (get spec "transition"))))
|
||||
|
||||
(deftest "innerHTML"
|
||||
(let ((spec (parse-swap-spec "innerHTML" false)))
|
||||
(assert-equal "innerHTML" (get spec "style"))))
|
||||
|
||||
(deftest "with transition true"
|
||||
(let ((spec (parse-swap-spec "innerHTML transition:true" false)))
|
||||
(assert-equal "innerHTML" (get spec "style"))
|
||||
(assert-true (get spec "transition"))))
|
||||
|
||||
(deftest "transition false overrides global"
|
||||
(let ((spec (parse-swap-spec "outerHTML transition:false" true)))
|
||||
(assert-equal "outerHTML" (get spec "style"))
|
||||
(assert-false (get spec "transition"))))
|
||||
|
||||
(deftest "global transition when not overridden"
|
||||
(let ((spec (parse-swap-spec "innerHTML" true)))
|
||||
(assert-equal "innerHTML" (get spec "style"))
|
||||
(assert-true (get spec "transition")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. parse-retry-spec — retry specification parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-retry-spec"
|
||||
|
||||
(deftest "nil returns nil"
|
||||
(assert-nil (parse-retry-spec nil)))
|
||||
|
||||
(deftest "exponential backoff"
|
||||
(let ((spec (parse-retry-spec "exponential:1000:30000")))
|
||||
(assert-equal "exponential" (get spec "strategy"))
|
||||
(assert-equal 1000 (get spec "start-ms"))
|
||||
(assert-equal 30000 (get spec "cap-ms"))))
|
||||
|
||||
(deftest "linear strategy"
|
||||
(let ((spec (parse-retry-spec "linear:2000:60000")))
|
||||
(assert-equal "linear" (get spec "strategy"))
|
||||
(assert-equal 2000 (get spec "start-ms"))
|
||||
(assert-equal 60000 (get spec "cap-ms")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. next-retry-ms — exponential backoff calculation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "next-retry-ms"
|
||||
|
||||
(deftest "doubles current"
|
||||
(assert-equal 2000 (next-retry-ms 1000 30000)))
|
||||
|
||||
(deftest "caps at maximum"
|
||||
(assert-equal 30000 (next-retry-ms 20000 30000)))
|
||||
|
||||
(deftest "exact cap"
|
||||
(assert-equal 30000 (next-retry-ms 15000 30000)))
|
||||
|
||||
(deftest "small initial"
|
||||
(assert-equal 200 (next-retry-ms 100 30000))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. filter-params — form parameter filtering
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "filter-params"
|
||||
|
||||
(deftest "nil passes all through"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 2 (len (filter-params nil params)))))
|
||||
|
||||
(deftest "none returns empty"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 0 (len (filter-params "none" params)))))
|
||||
|
||||
(deftest "star passes all"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 2 (len (filter-params "*" params)))))
|
||||
|
||||
(deftest "whitelist"
|
||||
(let ((params (list (list "name" "Jo") (list "age" "30") (list "secret" "x"))))
|
||||
(let ((filtered (filter-params "name,age" params)))
|
||||
(assert-equal 2 (len filtered)))))
|
||||
|
||||
(deftest "blacklist with not"
|
||||
(let ((params (list (list "name" "Jo") (list "csrf" "tok") (list "age" "30"))))
|
||||
(let ((filtered (filter-params "not csrf" params)))
|
||||
(assert-equal 2 (len filtered))))))
|
||||
170
web/test-orchestration.sx
Normal file
170
web/test-orchestration.sx
Normal file
@@ -0,0 +1,170 @@
|
||||
;; ==========================================================================
|
||||
;; test-orchestration.sx — Tests for orchestration.sx Phase 7c + 7d
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions mocked by test runner:
|
||||
;; now-ms, log-info, log-warn, execute-action, try-rerender-page
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. page-data-cache — basic cache operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "page-data-cache"
|
||||
|
||||
(deftest "cache-key bare page name"
|
||||
(assert-equal "my-page" (page-data-cache-key "my-page" nil)))
|
||||
|
||||
(deftest "cache-key with params"
|
||||
(let ((key (page-data-cache-key "my-page" {"id" "42"})))
|
||||
(assert-equal "my-page:id=42" key)))
|
||||
|
||||
(deftest "cache-set then get"
|
||||
(let ((key "test-cache-1"))
|
||||
(page-data-cache-set key {"items" (list 1 2 3)})
|
||||
(let ((result (page-data-cache-get key)))
|
||||
(assert-equal (list 1 2 3) (get result "items")))))
|
||||
|
||||
(deftest "cache miss returns nil"
|
||||
(assert-nil (page-data-cache-get "nonexistent-key"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. optimistic-cache-update — predicted mutation with snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-update"
|
||||
|
||||
(deftest "applies mutator to cached data"
|
||||
(let ((key "opt-test-1"))
|
||||
;; Seed the cache
|
||||
(page-data-cache-set key {"count" 10})
|
||||
;; Apply optimistic mutation
|
||||
(let ((predicted (optimistic-cache-update key
|
||||
(fn (data) (merge data {"count" 11})))))
|
||||
(assert-equal 11 (get predicted "count")))))
|
||||
|
||||
(deftest "updates cache with prediction"
|
||||
(let ((key "opt-test-2"))
|
||||
(page-data-cache-set key {"count" 5})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"count" 6})))
|
||||
;; Cache now has predicted value
|
||||
(let ((cached (page-data-cache-get key)))
|
||||
(assert-equal 6 (get cached "count")))))
|
||||
|
||||
(deftest "returns nil when no cached data"
|
||||
(let ((result (optimistic-cache-update "no-such-key"
|
||||
(fn (data) (merge data {"x" 1})))))
|
||||
(assert-nil result))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. optimistic-cache-revert — restore from snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-revert"
|
||||
|
||||
(deftest "reverts to original data"
|
||||
(let ((key "revert-test-1"))
|
||||
(page-data-cache-set key {"count" 10})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"count" 99})))
|
||||
;; Cache now has 99
|
||||
(assert-equal 99 (get (page-data-cache-get key) "count"))
|
||||
;; Revert
|
||||
(let ((restored (optimistic-cache-revert key)))
|
||||
(assert-equal 10 (get restored "count"))
|
||||
;; Cache is back to original
|
||||
(assert-equal 10 (get (page-data-cache-get key) "count")))))
|
||||
|
||||
(deftest "returns nil when no snapshot"
|
||||
(assert-nil (optimistic-cache-revert "never-mutated"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. optimistic-cache-confirm — discard snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-confirm"
|
||||
|
||||
(deftest "confirm clears snapshot"
|
||||
(let ((key "confirm-test-1"))
|
||||
(page-data-cache-set key {"val" "a"})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"val" "b"})))
|
||||
;; Confirm — accepts the optimistic value
|
||||
(optimistic-cache-confirm key)
|
||||
;; Revert should now return nil (no snapshot)
|
||||
(assert-nil (optimistic-cache-revert key))
|
||||
;; Cache still has optimistic value
|
||||
(assert-equal "b" (get (page-data-cache-get key) "val")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. offline-is-online? / offline-set-online! — connectivity tracking
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-connectivity"
|
||||
|
||||
(deftest "initially online"
|
||||
(assert-true (offline-is-online?)))
|
||||
|
||||
(deftest "set offline"
|
||||
(offline-set-online! false)
|
||||
(assert-false (offline-is-online?)))
|
||||
|
||||
(deftest "set back online"
|
||||
(offline-set-online! true)
|
||||
(assert-true (offline-is-online?))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. offline-queue-mutation — queue entries when offline
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-queue-mutation"
|
||||
|
||||
(deftest "queues an entry"
|
||||
;; Seed cache so optimistic update works
|
||||
(let ((key (page-data-cache-key "notes" nil)))
|
||||
(page-data-cache-set key {"items" (list "a" "b")})
|
||||
(let ((entry (offline-queue-mutation "add-note"
|
||||
{"text" "c"}
|
||||
"notes" nil
|
||||
(fn (data) (merge data {"items" (list "a" "b" "c")})))))
|
||||
(assert-equal "add-note" (get entry "action"))
|
||||
(assert-equal "pending" (get entry "status")))))
|
||||
|
||||
(deftest "pending count increases"
|
||||
;; Previous test queued 1 entry; count should be >= 1
|
||||
(assert-true (> (offline-pending-count) 0))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. offline-aware-mutation — routes by connectivity
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-aware-mutation"
|
||||
|
||||
(deftest "when online calls submit-mutation path"
|
||||
(offline-set-online! true)
|
||||
(let ((key (page-data-cache-key "test-page" nil)))
|
||||
(page-data-cache-set key {"v" 1})
|
||||
;; This will trigger execute-action (mocked) which calls success cb
|
||||
(let ((status nil))
|
||||
(offline-aware-mutation "test-page" nil "do-thing" {"x" 1}
|
||||
(fn (data) (merge data {"v" 2}))
|
||||
(fn (s) (set! status s)))
|
||||
;; Mock execute-action calls success immediately
|
||||
(assert-equal "confirmed" status))))
|
||||
|
||||
(deftest "when offline queues mutation"
|
||||
(offline-set-online! false)
|
||||
(let ((key (page-data-cache-key "test-page-2" nil)))
|
||||
(page-data-cache-set key {"v" 1})
|
||||
(let ((status nil))
|
||||
(offline-aware-mutation "test-page-2" nil "do-thing" {"x" 1}
|
||||
(fn (data) (merge data {"v" 2}))
|
||||
(fn (s) (set! status s)))
|
||||
(assert-equal "queued" status)))
|
||||
;; Clean up: go back online
|
||||
(offline-set-online! true)))
|
||||
708
web/test-router.sx
Normal file
708
web/test-router.sx
Normal file
@@ -0,0 +1,708 @@
|
||||
;; ==========================================================================
|
||||
;; test-router.sx — Tests for client-side route matching & SX URL algebra
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: router.sx
|
||||
;;
|
||||
;; No additional platform functions needed — router.sx is pure.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; split-path-segments
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "split-path-segments"
|
||||
(deftest "root path"
|
||||
(assert-equal (list) (split-path-segments "/")))
|
||||
|
||||
(deftest "single segment"
|
||||
(assert-equal (list "docs") (split-path-segments "/docs")))
|
||||
|
||||
(deftest "multiple segments"
|
||||
(assert-equal (list "docs" "hello") (split-path-segments "/docs/hello")))
|
||||
|
||||
(deftest "trailing slash stripped"
|
||||
(assert-equal (list "docs") (split-path-segments "/docs/")))
|
||||
|
||||
(deftest "deep path"
|
||||
(assert-equal (list "a" "b" "c" "d") (split-path-segments "/a/b/c/d"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; parse-route-pattern
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-route-pattern"
|
||||
(deftest "static pattern"
|
||||
(let ((segs (parse-route-pattern "/docs/intro")))
|
||||
(assert-length 2 segs)
|
||||
(assert-equal "literal" (get (first segs) "type"))
|
||||
(assert-equal "docs" (get (first segs) "value"))
|
||||
(assert-equal "literal" (get (nth segs 1) "type"))
|
||||
(assert-equal "intro" (get (nth segs 1) "value"))))
|
||||
|
||||
(deftest "pattern with param"
|
||||
(let ((segs (parse-route-pattern "/docs/<slug>")))
|
||||
(assert-length 2 segs)
|
||||
(assert-equal "literal" (get (first segs) "type"))
|
||||
(assert-equal "docs" (get (first segs) "value"))
|
||||
(assert-equal "param" (get (nth segs 1) "type"))
|
||||
(assert-equal "slug" (get (nth segs 1) "value"))))
|
||||
|
||||
(deftest "multiple params"
|
||||
(let ((segs (parse-route-pattern "/users/<uid>/posts/<pid>")))
|
||||
(assert-length 4 segs)
|
||||
(assert-equal "param" (get (nth segs 1) "type"))
|
||||
(assert-equal "uid" (get (nth segs 1) "value"))
|
||||
(assert-equal "param" (get (nth segs 3) "type"))
|
||||
(assert-equal "pid" (get (nth segs 3) "value"))))
|
||||
|
||||
(deftest "root pattern"
|
||||
(assert-equal (list) (parse-route-pattern "/"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; match-route
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "match-route"
|
||||
(deftest "exact match returns empty params"
|
||||
(let ((result (match-route "/docs/intro" "/docs/intro")))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-length 0 (keys result))))
|
||||
|
||||
(deftest "param match extracts value"
|
||||
(let ((result (match-route "/docs/hello" "/docs/<slug>")))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "hello" (get result "slug"))))
|
||||
|
||||
(deftest "no match returns nil"
|
||||
(assert-nil (match-route "/docs/hello" "/essays/<slug>"))
|
||||
(assert-nil (match-route "/docs" "/docs/<slug>")))
|
||||
|
||||
(deftest "segment count mismatch returns nil"
|
||||
(assert-nil (match-route "/a/b/c" "/a/<b>"))
|
||||
(assert-nil (match-route "/a" "/a/b")))
|
||||
|
||||
(deftest "root matches root"
|
||||
(let ((result (match-route "/" "/")))
|
||||
(assert-true (not (nil? result)))))
|
||||
|
||||
(deftest "multiple params extracted"
|
||||
(let ((result (match-route "/users/42/posts/99" "/users/<uid>/posts/<pid>")))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "42" (get result "uid"))
|
||||
(assert-equal "99" (get result "pid")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; find-matching-route
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "find-matching-route"
|
||||
(deftest "finds first matching route"
|
||||
(let ((routes (list
|
||||
{:pattern "/docs/" :parsed (parse-route-pattern "/docs/") :name "docs-index"}
|
||||
{:pattern "/docs/<slug>" :parsed (parse-route-pattern "/docs/<slug>") :name "docs-page"})))
|
||||
(let ((result (find-matching-route "/docs/hello" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "docs-page" (get result "name"))
|
||||
(assert-equal "hello" (get (get result "params") "slug")))))
|
||||
|
||||
(deftest "returns nil for no match"
|
||||
(let ((routes (list
|
||||
{:pattern "/docs/<slug>" :parsed (parse-route-pattern "/docs/<slug>") :name "docs-page"})))
|
||||
(assert-nil (find-matching-route "/essays/hello" routes))))
|
||||
|
||||
(deftest "matches exact routes before param routes"
|
||||
(let ((routes (list
|
||||
{:pattern "/docs/" :parsed (parse-route-pattern "/docs/") :name "docs-index"}
|
||||
{:pattern "/docs/<slug>" :parsed (parse-route-pattern "/docs/<slug>") :name "docs-page"})))
|
||||
(let ((result (find-matching-route "/docs/" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "docs-index" (get result "name")))))
|
||||
|
||||
(deftest "propagates stream flag from route"
|
||||
(let ((routes (list
|
||||
{:pattern "/demo/streaming"
|
||||
:parsed (parse-route-pattern "/demo/streaming")
|
||||
:name "streaming-demo"
|
||||
:stream true
|
||||
:has-data true})))
|
||||
(let ((result (find-matching-route "/demo/streaming" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal true (get result "stream"))
|
||||
(assert-equal true (get result "has-data")))))
|
||||
|
||||
(deftest "non-streaming route has no stream flag"
|
||||
(let ((routes (list
|
||||
{:pattern "/about"
|
||||
:parsed (parse-route-pattern "/about")
|
||||
:name "about"
|
||||
:has-data false})))
|
||||
(let ((result (find-matching-route "/about" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-nil (get result "stream"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; sx-url-to-path — SX expression URL → old-style path
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "sx-url-to-path"
|
||||
(deftest "simple two-level"
|
||||
(assert-equal "/language/docs/introduction"
|
||||
(sx-url-to-path "/(language.(doc.introduction))")))
|
||||
|
||||
(deftest "deep nesting"
|
||||
(assert-equal "/geography/hypermedia/reference/attributes"
|
||||
(sx-url-to-path "/(geography.(hypermedia.(reference.attributes)))")))
|
||||
|
||||
(deftest "section index"
|
||||
(assert-equal "/language"
|
||||
(sx-url-to-path "/(language)")))
|
||||
|
||||
(deftest "function name mapping — doc to docs"
|
||||
(assert-equal "/language/docs/getting-started"
|
||||
(sx-url-to-path "/(language.(doc.getting-started))")))
|
||||
|
||||
(deftest "function name mapping — spec to specs"
|
||||
(assert-equal "/language/specs/core"
|
||||
(sx-url-to-path "/(language.(spec.core))")))
|
||||
|
||||
(deftest "function name mapping — example to examples"
|
||||
(assert-equal "/geography/hypermedia/examples/click-to-load"
|
||||
(sx-url-to-path "/(geography.(hypermedia.(example.click-to-load)))")))
|
||||
|
||||
(deftest "function name mapping — essay to essays"
|
||||
(assert-equal "/etc/essays/sx-sucks"
|
||||
(sx-url-to-path "/(etc.(essay.sx-sucks))")))
|
||||
|
||||
(deftest "function name mapping — plan to plans"
|
||||
(assert-equal "/etc/plans/spec-explorer"
|
||||
(sx-url-to-path "/(etc.(plan.spec-explorer))")))
|
||||
|
||||
(deftest "function name mapping — test to testing"
|
||||
(assert-equal "/language/testing/eval"
|
||||
(sx-url-to-path "/(language.(test.eval))")))
|
||||
|
||||
(deftest "function name mapping — bootstrapper to bootstrappers"
|
||||
(assert-equal "/language/bootstrappers/python"
|
||||
(sx-url-to-path "/(language.(bootstrapper.python))")))
|
||||
|
||||
(deftest "function name mapping — protocol to protocols"
|
||||
(assert-equal "/applications/protocols/wire-format"
|
||||
(sx-url-to-path "/(applications.(protocol.wire-format))")))
|
||||
|
||||
(deftest "function name mapping — reference-detail to reference"
|
||||
(assert-equal "/geography/hypermedia/reference/attributes"
|
||||
(sx-url-to-path "/(geography.(hypermedia.(reference-detail.attributes)))")))
|
||||
|
||||
(deftest "non-SX URL returns nil"
|
||||
(assert-nil (sx-url-to-path "/language/docs/introduction"))
|
||||
(assert-nil (sx-url-to-path "https://example.com"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; find-matching-route with SX URLs
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "find-matching-route-sx-urls"
|
||||
(deftest "SX URL auto-converts for matching"
|
||||
(let ((routes (list
|
||||
{:pattern "/language/docs/<slug>"
|
||||
:parsed (parse-route-pattern "/language/docs/<slug>")
|
||||
:name "docs-page"})))
|
||||
(let ((result (find-matching-route "/(language.(doc.introduction))" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "docs-page" (get result "name"))
|
||||
(assert-equal "introduction" (get (get result "params") "slug"))))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; SX URL Resolution — Structural Navigation
|
||||
;; ==========================================================================
|
||||
|
||||
(defsuite "relative-sx-url?"
|
||||
(deftest "paren-form relative"
|
||||
(assert-true (relative-sx-url? "(.slug)"))
|
||||
(assert-true (relative-sx-url? "(..)"))
|
||||
(assert-true (relative-sx-url? "(..reactive.demo)")))
|
||||
|
||||
(deftest "bare-dot relative"
|
||||
(assert-true (relative-sx-url? ".slug"))
|
||||
(assert-true (relative-sx-url? ".."))
|
||||
(assert-true (relative-sx-url? "..."))
|
||||
(assert-true (relative-sx-url? ".:page.4")))
|
||||
|
||||
(deftest "absolute URLs are not relative"
|
||||
(assert-false (relative-sx-url? "/(language.(doc.intro))"))
|
||||
(assert-false (relative-sx-url? "/"))
|
||||
(assert-false (relative-sx-url? "/language/docs/intro")))
|
||||
|
||||
(deftest "special form URLs are not relative"
|
||||
(assert-false (relative-sx-url? "/(!source.(~essay))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: append at current level (1 dot)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: append (.slug)"
|
||||
(deftest "append to deep URL"
|
||||
(assert-equal "/(geography.(hypermedia.(example.progress-bar)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(.progress-bar)")))
|
||||
|
||||
(deftest "append to single-level URL"
|
||||
(assert-equal "/(language.intro)"
|
||||
(resolve-relative-url "/(language)" "(.intro)")))
|
||||
|
||||
(deftest "append with multi-token body"
|
||||
(assert-equal "/(geography.(hypermedia.(example.progress-bar.v2)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(.progress-bar.v2)")))
|
||||
|
||||
(deftest "bare-dot shorthand"
|
||||
(assert-equal "/(geography.(hypermedia.(example.progress-bar)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
".progress-bar"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: go up one level (2 dots)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: up one (..slug)"
|
||||
(deftest "sibling call"
|
||||
(assert-equal "/(geography.(hypermedia.(reactive.demo)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(..reactive.demo)")))
|
||||
|
||||
(deftest "just go up — no new content"
|
||||
(assert-equal "/(geography.(hypermedia))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(..)")))
|
||||
|
||||
(deftest "bare-dot shorthand for up"
|
||||
(assert-equal "/(geography.(hypermedia))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"..")))
|
||||
|
||||
(deftest "up from two-level URL"
|
||||
(assert-equal "/(language)"
|
||||
(resolve-relative-url "/(language.(doc))" "(..)")))
|
||||
|
||||
(deftest "up from single-level pops to root"
|
||||
(assert-equal "/"
|
||||
(resolve-relative-url "/(language)" "(..)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: go up two levels (3 dots)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: up two (...slug)"
|
||||
(deftest "up two and push"
|
||||
(assert-equal "/(geography.(marshes))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(...marshes)")))
|
||||
|
||||
(deftest "just up two — no content"
|
||||
(assert-equal "/(geography)"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(...)")))
|
||||
|
||||
(deftest "bare-dot shorthand for up two"
|
||||
(assert-equal "/(geography)"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"...")))
|
||||
|
||||
(deftest "up two from two-level pops to root"
|
||||
(assert-equal "/"
|
||||
(resolve-relative-url "/(language.(doc))" "(...)")))
|
||||
|
||||
(deftest "up two and push from deep URL"
|
||||
;; 4-level URL, ... = 3 dots = pop 2 levels → at hypermedia level
|
||||
(assert-equal "/(geography.(hypermedia.(reactive.demo)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(reference.(attributes))))"
|
||||
"(...reactive.demo)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: up N levels (N+1 dots)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: up N"
|
||||
(deftest "up three levels (4 dots) from 4-level URL"
|
||||
;; 4-level URL, .... = 4 dots = pop 3 levels → at geography level
|
||||
(assert-equal "/(geography)"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(reference.(attributes))))"
|
||||
"(....)")))
|
||||
|
||||
(deftest "up three and push from 4-level URL"
|
||||
;; 4 dots = pop 3 → at geography, then push new-section
|
||||
(assert-equal "/(geography.(new-section))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(reference.(attributes))))"
|
||||
"(....new-section)")))
|
||||
|
||||
(deftest "up four levels (5 dots) pops to root"
|
||||
(assert-equal "/"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(reference.(attributes))))"
|
||||
"(.....)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: current (1 dot, no body) = no-op
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: current level no-op"
|
||||
(deftest "dot with no body is identity"
|
||||
;; (.): dots=1, body="" → no positional, no keywords → current unchanged
|
||||
(assert-equal "/(language.(doc.intro))"
|
||||
(resolve-relative-url "/(language.(doc.intro))" "(.)")))
|
||||
|
||||
(deftest "bare dot shorthand"
|
||||
(assert-equal "/(language.(doc.intro))"
|
||||
(resolve-relative-url "/(language.(doc.intro))" "."))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; SX URL Resolution — Keyword Operations
|
||||
;; ==========================================================================
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Keyword set: absolute value
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: keyword set"
|
||||
(deftest "set keyword on URL without keywords"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals)))"
|
||||
"(.:page.4)")))
|
||||
|
||||
(deftest "replace existing keyword"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.4)")))
|
||||
|
||||
(deftest "set keyword with bare-dot shorthand"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
".:page.4")))
|
||||
|
||||
(deftest "set keyword on single-level URL"
|
||||
(assert-equal "/(language.:page.1)"
|
||||
(resolve-relative-url "/(language)" "(.:page.1)")))
|
||||
|
||||
(deftest "set multiple keywords"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4.:section.batch)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.4.:section.batch)")))
|
||||
|
||||
(deftest "add new keyword preserving existing"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.3.:section.batch)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:section.batch)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Keyword delta: +N / -N
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: keyword delta"
|
||||
(deftest "increment by 1"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.+1)")))
|
||||
|
||||
(deftest "decrement by 1"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.2)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.-1)")))
|
||||
|
||||
(deftest "increment by larger amount"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.13)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.+10)")))
|
||||
|
||||
(deftest "delta with bare-dot shorthand"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
".:page.+1")))
|
||||
|
||||
(deftest "delta on missing keyword uses literal"
|
||||
;; If :page doesn't exist, +1 is used as-is (not numeric delta)
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.+1)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals)))"
|
||||
"(.:page.+1)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Composed: structural + keyword
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: composed structural + keyword"
|
||||
(deftest "append slug + set keyword"
|
||||
(assert-equal "/(language.(spec.(explore.signals.batch.:page.1)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals)))"
|
||||
"(.batch.:page.1)")))
|
||||
|
||||
(deftest "sibling + set keyword"
|
||||
(assert-equal "/(language.(spec.(eval.:page.1)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(..eval.:page.1)")))
|
||||
|
||||
(deftest "up two + set keyword"
|
||||
(assert-equal "/(geography.(reactive.demo.:page.1))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example.progress-bar)))"
|
||||
"(...reactive.demo.:page.1)")))
|
||||
|
||||
(deftest "bare-dot composed"
|
||||
(assert-equal "/(language.(spec.(eval.:page.1)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"..eval.:page.1"))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; SX URL Parsing — parse-sx-url
|
||||
;; ==========================================================================
|
||||
|
||||
(defsuite "parse-sx-url"
|
||||
(deftest "home URL"
|
||||
(let ((parsed (parse-sx-url "/")))
|
||||
(assert-equal "home" (get parsed "type"))
|
||||
(assert-equal "/" (get parsed "raw"))))
|
||||
|
||||
(deftest "absolute SX URL"
|
||||
(let ((parsed (parse-sx-url "/(language.(doc.intro))")))
|
||||
(assert-equal "absolute" (get parsed "type"))))
|
||||
|
||||
(deftest "relative paren-form"
|
||||
(let ((parsed (parse-sx-url "(.slug)")))
|
||||
(assert-equal "relative" (get parsed "type"))))
|
||||
|
||||
(deftest "relative bare-dot"
|
||||
(let ((parsed (parse-sx-url ".slug")))
|
||||
(assert-equal "relative" (get parsed "type"))))
|
||||
|
||||
(deftest "relative double-dot"
|
||||
(let ((parsed (parse-sx-url "..")))
|
||||
(assert-equal "relative" (get parsed "type"))))
|
||||
|
||||
(deftest "direct component"
|
||||
(let ((parsed (parse-sx-url "/(~essay-sx-sucks)")))
|
||||
(assert-equal "direct-component" (get parsed "type"))
|
||||
(assert-equal "~essay-sx-sucks" (get parsed "name"))))
|
||||
|
||||
(deftest "old-style path"
|
||||
(let ((parsed (parse-sx-url "/language/docs/intro")))
|
||||
(assert-equal "path" (get parsed "type")))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; URL Special Forms (! prefix)
|
||||
;; ==========================================================================
|
||||
|
||||
(defsuite "url-special-form?"
|
||||
(deftest "known special forms"
|
||||
(assert-true (url-special-form? "!source"))
|
||||
(assert-true (url-special-form? "!inspect"))
|
||||
(assert-true (url-special-form? "!diff"))
|
||||
(assert-true (url-special-form? "!search"))
|
||||
(assert-true (url-special-form? "!raw"))
|
||||
(assert-true (url-special-form? "!json")))
|
||||
|
||||
(deftest "unknown bang-prefix is not a special form"
|
||||
(assert-false (url-special-form? "!unknown"))
|
||||
(assert-false (url-special-form? "!foo")))
|
||||
|
||||
(deftest "non-bang names are not special forms"
|
||||
(assert-false (url-special-form? "source"))
|
||||
(assert-false (url-special-form? "language"))
|
||||
(assert-false (url-special-form? "~essay"))))
|
||||
|
||||
|
||||
(defsuite "parse-sx-url: special forms"
|
||||
(deftest "source special form"
|
||||
(let ((parsed (parse-sx-url "/(!source.(~essay-sx-sucks))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!source" (get parsed "form"))
|
||||
(assert-equal "(~essay-sx-sucks)" (get parsed "inner"))))
|
||||
|
||||
(deftest "inspect special form"
|
||||
(let ((parsed (parse-sx-url "/(!inspect.(language.(doc.primitives)))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!inspect" (get parsed "form"))
|
||||
(assert-equal "(language.(doc.primitives))" (get parsed "inner"))))
|
||||
|
||||
(deftest "diff special form with two args"
|
||||
(let ((parsed (parse-sx-url "/(!diff.(language.(spec.signals)).(language.(spec.eval)))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!diff" (get parsed "form"))
|
||||
(assert-equal "(language.(spec.signals)).(language.(spec.eval))" (get parsed "inner"))))
|
||||
|
||||
(deftest "raw special form"
|
||||
(let ((parsed (parse-sx-url "/(!raw.(~some-component))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!raw" (get parsed "form"))
|
||||
(assert-equal "(~some-component)" (get parsed "inner"))))
|
||||
|
||||
(deftest "json special form"
|
||||
(let ((parsed (parse-sx-url "/(!json.(language.(doc.primitives)))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!json" (get parsed "form"))
|
||||
(assert-equal "(language.(doc.primitives))" (get parsed "inner")))))
|
||||
|
||||
|
||||
(defsuite "url-special-form-name"
|
||||
(deftest "extracts form name"
|
||||
(assert-equal "!source"
|
||||
(url-special-form-name "/(!source.(~essay))")))
|
||||
|
||||
(deftest "returns nil for non-special-form"
|
||||
(assert-nil (url-special-form-name "/(language.(doc.intro))"))
|
||||
(assert-nil (url-special-form-name "/"))
|
||||
(assert-nil (url-special-form-name "(.slug)"))))
|
||||
|
||||
|
||||
(defsuite "url-special-form-inner"
|
||||
(deftest "extracts inner expression"
|
||||
(assert-equal "(~essay)"
|
||||
(url-special-form-inner "/(!source.(~essay))")))
|
||||
|
||||
(deftest "extracts multi-arg inner"
|
||||
(assert-equal "(a).(b)"
|
||||
(url-special-form-inner "/(!diff.(a).(b))")))
|
||||
|
||||
(deftest "returns nil for non-special-form"
|
||||
(assert-nil (url-special-form-inner "/(language.(doc.intro))"))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; Internal helpers — additional edge cases
|
||||
;; ==========================================================================
|
||||
|
||||
(defsuite "internal: _pop-sx-url-level"
|
||||
(deftest "pop three-level"
|
||||
(assert-equal "/(a.(b))"
|
||||
(_pop-sx-url-level "/(a.(b.(c)))")))
|
||||
|
||||
(deftest "pop two-level"
|
||||
(assert-equal "/(a)"
|
||||
(_pop-sx-url-level "/(a.(b))")))
|
||||
|
||||
(deftest "pop single-level to root"
|
||||
(assert-equal "/"
|
||||
(_pop-sx-url-level "/(a)")))
|
||||
|
||||
(deftest "pop root stays root"
|
||||
(assert-equal "/"
|
||||
(_pop-sx-url-level "/"))))
|
||||
|
||||
(defsuite "internal: _extract-innermost"
|
||||
(deftest "single-level URL"
|
||||
(let ((parts (_extract-innermost "/(language)")))
|
||||
(assert-equal "/(" (get parts "before"))
|
||||
(assert-equal "language" (get parts "content"))
|
||||
(assert-equal ")" (get parts "suffix"))))
|
||||
|
||||
(deftest "two-level URL"
|
||||
(let ((parts (_extract-innermost "/(language.(doc.intro))")))
|
||||
(assert-equal "/(language.(" (get parts "before"))
|
||||
(assert-equal "doc.intro" (get parts "content"))
|
||||
(assert-equal "))" (get parts "suffix"))))
|
||||
|
||||
(deftest "three-level URL with keywords"
|
||||
(let ((parts (_extract-innermost "/(a.(b.(c.d.:page.3)))")))
|
||||
(assert-equal "/(a.(b.(" (get parts "before"))
|
||||
(assert-equal "c.d.:page.3" (get parts "content"))
|
||||
(assert-equal ")))" (get parts "suffix")))))
|
||||
|
||||
(defsuite "internal: _find-keyword-value"
|
||||
(deftest "finds keyword"
|
||||
(assert-equal "3"
|
||||
(_find-keyword-value "explore.signals.:page.3" ":page")))
|
||||
|
||||
(deftest "returns nil when not found"
|
||||
(assert-nil (_find-keyword-value "explore.signals" ":page")))
|
||||
|
||||
(deftest "finds among multiple keywords"
|
||||
(assert-equal "batch"
|
||||
(_find-keyword-value "explore.signals.:page.3.:section.batch" ":section"))))
|
||||
|
||||
(defsuite "internal: _set-keyword-in-content"
|
||||
(deftest "replace existing"
|
||||
(assert-equal "a.b.:page.4"
|
||||
(_set-keyword-in-content "a.b.:page.3" ":page" "4")))
|
||||
|
||||
(deftest "append when missing"
|
||||
(assert-equal "a.b.:page.1"
|
||||
(_set-keyword-in-content "a.b" ":page" "1")))
|
||||
|
||||
(deftest "replace with multiple keywords present"
|
||||
(assert-equal "a.:page.4.:section.batch"
|
||||
(_set-keyword-in-content "a.:page.3.:section.batch" ":page" "4"))))
|
||||
|
||||
(defsuite "internal: _is-delta-value?"
|
||||
(deftest "positive delta"
|
||||
(assert-true (_is-delta-value? "+1"))
|
||||
(assert-true (_is-delta-value? "+10")))
|
||||
|
||||
(deftest "negative delta"
|
||||
(assert-true (_is-delta-value? "-1"))
|
||||
(assert-true (_is-delta-value? "-10")))
|
||||
|
||||
(deftest "bare minus is not delta"
|
||||
(assert-false (_is-delta-value? "-")))
|
||||
|
||||
(deftest "bare plus is not delta"
|
||||
(assert-false (_is-delta-value? "+")))
|
||||
|
||||
(deftest "plain number is not delta"
|
||||
(assert-false (_is-delta-value? "3"))
|
||||
(assert-false (_is-delta-value? "0")))
|
||||
|
||||
(deftest "empty string is not delta"
|
||||
(assert-false (_is-delta-value? ""))))
|
||||
|
||||
(defsuite "internal: _apply-delta"
|
||||
(deftest "increment"
|
||||
(assert-equal "4" (_apply-delta "3" "+1")))
|
||||
|
||||
(deftest "decrement"
|
||||
(assert-equal "2" (_apply-delta "3" "-1")))
|
||||
|
||||
(deftest "large increment"
|
||||
(assert-equal "13" (_apply-delta "3" "+10")))
|
||||
|
||||
(deftest "non-numeric current falls back"
|
||||
(assert-equal "+1" (_apply-delta "abc" "+1"))))
|
||||
216
web/test-signals.sx
Normal file
216
web/test-signals.sx
Normal file
@@ -0,0 +1,216 @@
|
||||
;; ==========================================================================
|
||||
;; test-signals.sx — Tests for signals and reactive islands
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: signals.sx, eval.sx (defisland)
|
||||
;;
|
||||
;; Note: Multi-expression lambda bodies are wrapped in (do ...) for
|
||||
;; compatibility with the hand-written evaluator which only supports
|
||||
;; single-expression lambda bodies.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Signal creation and basic read/write
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "signal basics"
|
||||
(deftest "signal creates a reactive container"
|
||||
(let ((s (signal 42)))
|
||||
(assert-true (signal? s))
|
||||
(assert-equal 42 (deref s))))
|
||||
|
||||
(deftest "deref on non-signal passes through"
|
||||
(assert-equal 5 (deref 5))
|
||||
(assert-equal "hello" (deref "hello"))
|
||||
(assert-nil (deref nil)))
|
||||
|
||||
(deftest "reset! changes value"
|
||||
(let ((s (signal 0)))
|
||||
(reset! s 10)
|
||||
(assert-equal 10 (deref s))))
|
||||
|
||||
(deftest "reset! does not notify when value unchanged"
|
||||
(let ((s (signal 5))
|
||||
(count (signal 0)))
|
||||
(effect (fn () (do (deref s) (swap! count inc))))
|
||||
;; Effect runs once on creation → count=1
|
||||
(let ((c1 (deref count)))
|
||||
(reset! s 5) ;; same value — no notification
|
||||
(assert-equal c1 (deref count)))))
|
||||
|
||||
(deftest "swap! applies function to current value"
|
||||
(let ((s (signal 10)))
|
||||
(swap! s inc)
|
||||
(assert-equal 11 (deref s))))
|
||||
|
||||
(deftest "swap! passes extra args"
|
||||
(let ((s (signal 10)))
|
||||
(swap! s + 5)
|
||||
(assert-equal 15 (deref s)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Computed signals
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "computed"
|
||||
(deftest "computed derives initial value"
|
||||
(let ((a (signal 3))
|
||||
(b (signal 4))
|
||||
(sum (computed (fn () (+ (deref a) (deref b))))))
|
||||
(assert-equal 7 (deref sum))))
|
||||
|
||||
(deftest "computed updates when dependency changes"
|
||||
(let ((a (signal 2))
|
||||
(doubled (computed (fn () (* 2 (deref a))))))
|
||||
(assert-equal 4 (deref doubled))
|
||||
(reset! a 5)
|
||||
(assert-equal 10 (deref doubled))))
|
||||
|
||||
(deftest "computed chains"
|
||||
(let ((base (signal 1))
|
||||
(doubled (computed (fn () (* 2 (deref base)))))
|
||||
(quadrupled (computed (fn () (* 2 (deref doubled))))))
|
||||
(assert-equal 4 (deref quadrupled))
|
||||
(reset! base 3)
|
||||
(assert-equal 12 (deref quadrupled)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Effects
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "effects"
|
||||
(deftest "effect runs immediately"
|
||||
(let ((ran (signal false)))
|
||||
(effect (fn () (reset! ran true)))
|
||||
(assert-true (deref ran))))
|
||||
|
||||
(deftest "effect re-runs when dependency changes"
|
||||
(let ((source (signal "a"))
|
||||
(log (signal (list))))
|
||||
(effect (fn ()
|
||||
(swap! log (fn (l) (append l (deref source))))))
|
||||
;; Initial run logs "a"
|
||||
(assert-equal (list "a") (deref log))
|
||||
;; Change triggers re-run
|
||||
(reset! source "b")
|
||||
(assert-equal (list "a" "b") (deref log))))
|
||||
|
||||
(deftest "effect dispose stops tracking"
|
||||
(let ((source (signal 0))
|
||||
(count (signal 0)))
|
||||
(let ((dispose (effect (fn () (do
|
||||
(deref source)
|
||||
(swap! count inc))))))
|
||||
;; Effect ran once
|
||||
(assert-equal 1 (deref count))
|
||||
;; Trigger
|
||||
(reset! source 1)
|
||||
(assert-equal 2 (deref count))
|
||||
;; Dispose
|
||||
(dispose)
|
||||
;; Should NOT trigger
|
||||
(reset! source 2)
|
||||
(assert-equal 2 (deref count)))))
|
||||
|
||||
(deftest "effect cleanup runs before re-run"
|
||||
(let ((source (signal 0))
|
||||
(cleanups (signal 0)))
|
||||
(effect (fn () (do
|
||||
(deref source)
|
||||
(fn () (swap! cleanups inc))))) ;; return cleanup fn
|
||||
;; No cleanup yet (first run)
|
||||
(assert-equal 0 (deref cleanups))
|
||||
;; Change triggers cleanup of previous run
|
||||
(reset! source 1)
|
||||
(assert-equal 1 (deref cleanups)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Batch
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "batch"
|
||||
(deftest "batch defers notifications"
|
||||
(let ((a (signal 0))
|
||||
(b (signal 0))
|
||||
(run-count (signal 0)))
|
||||
(effect (fn () (do
|
||||
(deref a) (deref b)
|
||||
(swap! run-count inc))))
|
||||
;; Initial run
|
||||
(assert-equal 1 (deref run-count))
|
||||
;; Without batch: 2 writes → 2 effect runs
|
||||
;; With batch: 2 writes → 1 effect run
|
||||
(batch (fn () (do
|
||||
(reset! a 1)
|
||||
(reset! b 2))))
|
||||
;; Should be 2 (initial + 1 batched), not 3
|
||||
(assert-equal 2 (deref run-count)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defisland
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "defisland"
|
||||
(deftest "defisland creates an island"
|
||||
(defisland ~test-island (&key value)
|
||||
(list "island" value))
|
||||
(assert-true (island? ~test-island)))
|
||||
|
||||
(deftest "island is callable like component"
|
||||
(defisland ~greeting (&key name)
|
||||
(str "Hello, " name "!"))
|
||||
(assert-equal "Hello, World!" (~greeting :name "World")))
|
||||
|
||||
(deftest "island accepts children"
|
||||
(defisland ~wrapper (&rest children)
|
||||
(list "wrap" children))
|
||||
(assert-equal (list "wrap" (list "a" "b"))
|
||||
(~wrapper "a" "b"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Scope integration — reactive tracking uses scope-push!/scope-pop!
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scope integration"
|
||||
(deftest "deref outside reactive scope does not subscribe"
|
||||
(let ((s (signal 42)))
|
||||
;; Reading outside any reactive context should not add subscribers
|
||||
(assert-equal 42 (deref s))
|
||||
(assert-equal 0 (len (signal-subscribers s)))))
|
||||
|
||||
(deftest "computed uses scope for tracking"
|
||||
(let ((a (signal 1))
|
||||
(b (signal 2))
|
||||
(sum (computed (fn () (+ (deref a) (deref b))))))
|
||||
;; Each signal should have exactly 1 subscriber (the computed's recompute)
|
||||
(assert-equal 1 (len (signal-subscribers a)))
|
||||
(assert-equal 1 (len (signal-subscribers b)))
|
||||
;; Verify computed value
|
||||
(assert-equal 3 (deref sum))))
|
||||
|
||||
(deftest "nested effects with overlapping deps use scope correctly"
|
||||
(let ((shared (signal 0))
|
||||
(inner-only (signal 0))
|
||||
(outer-count (signal 0))
|
||||
(inner-count (signal 0)))
|
||||
;; Outer effect tracks shared
|
||||
(effect (fn () (do (deref shared) (swap! outer-count inc))))
|
||||
;; Inner effect tracks shared AND inner-only
|
||||
(effect (fn () (do (deref shared) (deref inner-only) (swap! inner-count inc))))
|
||||
;; Both ran once
|
||||
(assert-equal 1 (deref outer-count))
|
||||
(assert-equal 1 (deref inner-count))
|
||||
;; Changing shared triggers both
|
||||
(reset! shared 1)
|
||||
(assert-equal 2 (deref outer-count))
|
||||
(assert-equal 2 (deref inner-count))
|
||||
;; Changing inner-only triggers only inner
|
||||
(reset! inner-only 1)
|
||||
(assert-equal 2 (deref outer-count))
|
||||
(assert-equal 3 (deref inner-count)))))
|
||||
Reference in New Issue
Block a user