Enforce SX boundary contract via boundary.sx spec + runtime validation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m33s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m33s
Add boundary.sx declaring all 34 I/O primitives, 32 page helpers, and 9 allowed boundary types. Runtime validation in boundary.py checks every registration against the spec — undeclared primitives/helpers crash at startup with SX_BOUNDARY_STRICT=1 (now set in both dev and prod). Key changes: - Move 5 I/O-in-disguise primitives (app-url, asset-url, config, jinja-global, relations-from) from primitives.py to primitives_io.py - Remove duplicate url-for/route-prefix from primitives.py (already in IO) - Fix parse-datetime to return ISO string instead of raw datetime - Add datetime→isoformat conversion in _convert_result at the edge - Wrap page helper return values with boundary type validation - Replace all SxExpr(f"...") patterns with sx_call() or _sx_fragment() - Add assert declaration to primitives.sx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
85
shared/sx/ref/BOUNDARY.md
Normal file
85
shared/sx/ref/BOUNDARY.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# SX Boundary Enforcement
|
||||
|
||||
## Principle
|
||||
|
||||
SX is an uninterrupted island of pure evaluation. Host code (Python, JavaScript, Rust, etc.) interacts with it only through declared entry points. The specification enforces this — violations are errors, not style suggestions.
|
||||
|
||||
## The Three Tiers
|
||||
|
||||
### Tier 1: Pure Primitives
|
||||
|
||||
Declared in `primitives.sx`. Stateless, synchronous, no side effects. Available in every SX environment on every target.
|
||||
|
||||
Examples: `+`, `str`, `map`, `get`, `concat`, `merge`
|
||||
|
||||
### Tier 2: I/O Primitives
|
||||
|
||||
Declared in `boundary.sx`. Async, side-effectful, require host context (request, config, services). Server-side only.
|
||||
|
||||
Examples: `frag`, `query`, `current-user`, `csrf-token`, `request-arg`
|
||||
|
||||
### Tier 3: Page Helpers
|
||||
|
||||
Declared in `boundary.sx` with a `:service` scope. Registered per-app, return data that `.sx` components render. Server-side only.
|
||||
|
||||
Examples: `highlight` (sx), `editor-data` (blog), `all-markets-data` (market)
|
||||
|
||||
## Boundary Types
|
||||
|
||||
Only these types may cross the host-SX boundary:
|
||||
|
||||
| Type | Python | JavaScript | Rust (future) |
|
||||
|------|--------|-----------|----------------|
|
||||
| number | `int`, `float` | `number` | `f64` |
|
||||
| string | `str` | `string` | `String` |
|
||||
| boolean | `bool` | `boolean` | `bool` |
|
||||
| nil | `NIL` sentinel | `NIL` sentinel | `SxValue::Nil` |
|
||||
| keyword | `str` (colon-prefixed) | `string` | `String` |
|
||||
| list | `list` | `Array` | `Vec<SxValue>` |
|
||||
| dict | `dict` | `Object` / `Map` | `HashMap<String, SxValue>` |
|
||||
| sx-source | `SxExpr` wrapper | `string` | `String` |
|
||||
| style-value | `StyleValue` | `StyleValue` | `StyleValue` |
|
||||
|
||||
**NOT allowed:** ORM models, datetime objects, request objects, raw callables, framework types. Convert at the edge before crossing.
|
||||
|
||||
## Enforcement Mechanism
|
||||
|
||||
The bootstrappers (`bootstrap_js.py`, `bootstrap_py.py`, future `bootstrap_rs.py`, etc.) read `boundary.sx` and emit target-native validation:
|
||||
|
||||
- **Typed targets (Rust, Haskell, TypeScript):** Boundary types become an enum/ADT/discriminated union. Registration functions have type signatures that reject non-SX values at compile time. You literally cannot register a primitive that returns a `datetime` — it won't typecheck.
|
||||
|
||||
- **Python + mypy:** Boundary types become a `Protocol`/`Union` type. `validate_boundary_value()` checks at runtime; mypy catches most violations statically.
|
||||
|
||||
- **JavaScript:** Runtime validation only. `registerPrimitive()` checks the name against the declared set. Boundary type checking is runtime.
|
||||
|
||||
## The Contract
|
||||
|
||||
1. **Spec-first.** Every primitive, I/O function, and page helper must be declared in `primitives.sx` or `boundary.sx` before it can be registered. Undeclared registration = error.
|
||||
|
||||
2. **SX types only.** Values crossing the boundary must be SX-typed. Host-native types (datetime, ORM models, request objects) must be converted to dicts/strings at the edge.
|
||||
|
||||
3. **Data in, markup out.** Python returns data (dicts, lists, strings). `.sx` files compose markup. No SX source construction in Python — no f-strings, no string concatenation, no `SxExpr(f"...")`.
|
||||
|
||||
4. **Closed island.** SX code can only call symbols in its env + declared primitives. There is no FFI, no `eval-python`, no escape hatch from inside SX.
|
||||
|
||||
5. **Fail fast.** Violations are runtime errors (startup crash), not warnings. For typed targets, they're compile errors.
|
||||
|
||||
## Adding a New Primitive
|
||||
|
||||
1. Add declaration to `primitives.sx` (pure) or `boundary.sx` (I/O / page helper)
|
||||
2. Implement in the target language's primitive file
|
||||
3. The bootstrapper-emitted validator will accept it on next rebuild/restart
|
||||
4. If you skip step 1, the app crashes on startup telling you exactly what's missing
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
shared/sx/ref/
|
||||
primitives.sx — Pure primitive declarations
|
||||
boundary.sx — I/O primitive + page helper + boundary type declarations
|
||||
bootstrap_js.py — JS bootstrapper (reads both, emits validation)
|
||||
bootstrap_py.py — Python bootstrapper (reads both, emits validation)
|
||||
eval.sx — Evaluator spec (symbol resolution, env model)
|
||||
parser.sx — Parser spec
|
||||
render.sx — Renderer spec (shared registries)
|
||||
```
|
||||
456
shared/sx/ref/boundary.sx
Normal file
456
shared/sx/ref/boundary.sx
Normal file
@@ -0,0 +1,456 @@
|
||||
;; ==========================================================================
|
||||
;; boundary.sx — SX boundary contract
|
||||
;;
|
||||
;; Declares everything allowed to cross the host-SX boundary:
|
||||
;; I/O primitives (Tier 2) and page helpers (Tier 3).
|
||||
;;
|
||||
;; Pure primitives (Tier 1) are declared in primitives.sx.
|
||||
;; This file declares what primitives.sx does NOT cover:
|
||||
;; async/side-effectful host functions that need request context.
|
||||
;;
|
||||
;; Format:
|
||||
;; (define-io-primitive "name"
|
||||
;; :params (param1 param2 &key ...)
|
||||
;; :returns "type"
|
||||
;; :async true
|
||||
;; :doc "description"
|
||||
;; :context :request)
|
||||
;;
|
||||
;; (define-page-helper "name"
|
||||
;; :params (param1 param2)
|
||||
;; :returns "type"
|
||||
;; :service "service-name")
|
||||
;;
|
||||
;; Bootstrappers read this file and emit frozen sets + validation
|
||||
;; functions for the target language.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 1: Pure primitives — declared in primitives.sx
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(declare-tier :pure :source "primitives.sx")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 2: I/O primitives — async, side-effectful, need host context
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-io-primitive "frag"
|
||||
:params (service frag-type &key)
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Fetch cross-service HTML fragment."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "query"
|
||||
:params (service query-name &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Fetch data from another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "action"
|
||||
:params (service action-name &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call an action on another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "current-user"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:async true
|
||||
:doc "Current authenticated user dict, or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "htmx-request?"
|
||||
:params ()
|
||||
:returns "boolean"
|
||||
:async true
|
||||
:doc "True if current request has HX-Request header."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "service"
|
||||
:params (service-or-method &rest args &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-arg"
|
||||
:params (name &rest default)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a query string argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-path"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Current request path."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "nav-tree"
|
||||
:params ()
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Navigation tree as list of node dicts."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "get-children"
|
||||
:params (&key parent-type parent-id)
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Fetch child entities for a parent."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "g"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a value from the Quart request-local g object."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "csrf-token"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Current CSRF token string."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "abort"
|
||||
:params (status &rest message)
|
||||
:returns "nil"
|
||||
:async true
|
||||
:doc "Raise HTTP error from SX."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "url-for"
|
||||
:params (endpoint &key)
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Generate URL for a named endpoint."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "route-prefix"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Service URL prefix for dev/prod routing."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "root-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "post-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with post-level header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "select-colours"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Shared select/hover CSS class string."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "account-nav-ctx"
|
||||
:params ()
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Account nav fragments, or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "app-rights"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "User rights dict from g.rights."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "federation-actor-ctx"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:async true
|
||||
:doc "Serialized ActivityPub actor dict or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-view-args"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a URL view argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "cart-page-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with cart page header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-calendar-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events calendar header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-day-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events day header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-entry-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events entry header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-slot-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events slot header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-ticket-type-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with ticket type header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "market-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with market header data."
|
||||
:context :request)
|
||||
|
||||
;; Moved from primitives.py — these need host context (infra/config/Quart)
|
||||
|
||||
(define-io-primitive "app-url"
|
||||
:params (service &rest path)
|
||||
:returns "string"
|
||||
: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"
|
||||
:async false
|
||||
:doc "Versioned static asset URL."
|
||||
:context :config)
|
||||
|
||||
(define-io-primitive "config"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async false
|
||||
:doc "Read a value from app-config.yaml."
|
||||
:context :config)
|
||||
|
||||
(define-io-primitive "jinja-global"
|
||||
:params (key &rest default)
|
||||
:returns "any"
|
||||
:async false
|
||||
:doc "Read a Jinja environment global."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "relations-from"
|
||||
:params (entity-type)
|
||||
:returns "list"
|
||||
:async false
|
||||
:doc "List of RelationDef dicts for an entity type."
|
||||
:context :config)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 3: Page helpers — service-scoped, registered per app
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; SX docs service
|
||||
(define-page-helper "highlight"
|
||||
:params (code lang)
|
||||
:returns "sx-source"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "primitives-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "reference-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "attr-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "header-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "event-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "read-spec-file"
|
||||
:params (filename)
|
||||
:returns "string"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "bootstrapper-data"
|
||||
:params (target)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
;; Blog service
|
||||
(define-page-helper "editor-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "editor-page-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-data-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-preview-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-entries-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-settings-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-edit-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
;; Events service
|
||||
(define-page-helper "calendar-admin-data"
|
||||
:params (&key calendar-slug)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "day-admin-data"
|
||||
:params (&key calendar-slug year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "slots-data"
|
||||
:params (&key calendar-slug)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "slot-data"
|
||||
:params (&key calendar-slug slot-id)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "entry-data"
|
||||
:params (&key calendar-slug entry-id)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "entry-admin-data"
|
||||
:params (&key calendar-slug entry-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-types-data"
|
||||
:params (&key calendar-slug entry-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-type-data"
|
||||
:params (&key calendar-slug entry-id ticket-type-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "tickets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-detail-data"
|
||||
:params (&key code)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-admin-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "markets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
;; Market service
|
||||
(define-page-helper "all-markets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "page-markets-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "page-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "market-home-data"
|
||||
:params (&key page-slug market-slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Boundary types — what's allowed to cross the host-SX boundary
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-boundary-types
|
||||
(list "number" "string" "boolean" "nil" "keyword"
|
||||
"list" "dict" "sx-source" "style-value"))
|
||||
107
shared/sx/ref/boundary_parser.py
Normal file
107
shared/sx/ref/boundary_parser.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Parse boundary.sx and primitives.sx to extract declared names.
|
||||
|
||||
Shared by both bootstrap_py.py and bootstrap_js.py, and used at runtime
|
||||
by the validation module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# Allow standalone use (from bootstrappers) or in-project imports
|
||||
try:
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
|
||||
except ImportError:
|
||||
import sys
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
|
||||
sys.path.insert(0, _PROJECT)
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
|
||||
|
||||
|
||||
def _ref_dir() -> str:
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _read_file(filename: str) -> str:
|
||||
filepath = os.path.join(_ref_dir(), filename)
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _extract_keyword_arg(expr: list, key: str) -> Any:
|
||||
"""Extract :key value from a flat keyword-arg list."""
|
||||
for i, item in enumerate(expr):
|
||||
if isinstance(item, Keyword) and item.name == key and i + 1 < len(expr):
|
||||
return expr[i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def parse_primitives_sx() -> frozenset[str]:
|
||||
"""Parse primitives.sx and return frozenset of declared pure primitive names."""
|
||||
source = _read_file("primitives.sx")
|
||||
exprs = parse_all(source)
|
||||
names: set[str] = set()
|
||||
for expr in exprs:
|
||||
if (isinstance(expr, list) and len(expr) >= 2
|
||||
and isinstance(expr[0], Symbol)
|
||||
and expr[0].name == "define-primitive"):
|
||||
name = expr[1]
|
||||
if isinstance(name, str):
|
||||
names.add(name)
|
||||
return frozenset(names)
|
||||
|
||||
|
||||
def parse_boundary_sx() -> tuple[frozenset[str], dict[str, frozenset[str]]]:
|
||||
"""Parse boundary.sx and return (io_names, {service: helper_names}).
|
||||
|
||||
Returns:
|
||||
io_names: frozenset of declared I/O primitive names
|
||||
helpers: dict mapping service name to frozenset of helper names
|
||||
"""
|
||||
source = _read_file("boundary.sx")
|
||||
exprs = parse_all(source)
|
||||
io_names: set[str] = set()
|
||||
helpers: dict[str, set[str]] = {}
|
||||
|
||||
for expr in exprs:
|
||||
if not isinstance(expr, list) or not expr:
|
||||
continue
|
||||
head = expr[0]
|
||||
if not isinstance(head, Symbol):
|
||||
continue
|
||||
|
||||
if head.name == "define-io-primitive":
|
||||
name = expr[1]
|
||||
if isinstance(name, str):
|
||||
io_names.add(name)
|
||||
|
||||
elif head.name == "define-page-helper":
|
||||
name = expr[1]
|
||||
service = _extract_keyword_arg(expr, "service")
|
||||
if isinstance(name, str) and isinstance(service, str):
|
||||
helpers.setdefault(service, set()).add(name)
|
||||
|
||||
frozen_helpers = {svc: frozenset(names) for svc, names in helpers.items()}
|
||||
return frozenset(io_names), frozen_helpers
|
||||
|
||||
|
||||
def parse_boundary_types() -> frozenset[str]:
|
||||
"""Parse boundary.sx and return the declared boundary type names."""
|
||||
source = _read_file("boundary.sx")
|
||||
exprs = parse_all(source)
|
||||
for expr in exprs:
|
||||
if (isinstance(expr, list) and len(expr) >= 2
|
||||
and isinstance(expr[0], Symbol)
|
||||
and expr[0].name == "define-boundary-types"):
|
||||
type_list = expr[1]
|
||||
if isinstance(type_list, list):
|
||||
# (list "number" "string" ...)
|
||||
return frozenset(
|
||||
item for item in type_list
|
||||
if isinstance(item, str)
|
||||
)
|
||||
return frozenset()
|
||||
@@ -185,7 +185,10 @@
|
||||
|
||||
(cond
|
||||
(nil? variant)
|
||||
(append! base-decls decls)
|
||||
(if (is-child-selector-atom? base)
|
||||
(append! pseudo-rules
|
||||
(list ">:not(:first-child)" decls))
|
||||
(append! base-decls decls))
|
||||
|
||||
(dict-has? _responsive-breakpoints variant)
|
||||
(append! media-rules
|
||||
@@ -222,23 +225,23 @@
|
||||
(fn (mr)
|
||||
(set! hash-input
|
||||
(str hash-input "@" (first mr) "{" (nth mr 1) "}")))
|
||||
(chunk-every media-rules 2))
|
||||
media-rules)
|
||||
(for-each
|
||||
(fn (pr)
|
||||
(set! hash-input
|
||||
(str hash-input (first pr) "{" (nth pr 1) "}")))
|
||||
(chunk-every pseudo-rules 2))
|
||||
pseudo-rules)
|
||||
(for-each
|
||||
(fn (kf)
|
||||
(set! hash-input (str hash-input (nth kf 1))))
|
||||
(chunk-every kf-needed 2))
|
||||
kf-needed)
|
||||
|
||||
(let ((cn (str "sx-" (hash-style hash-input)))
|
||||
(sv (make-style-value cn
|
||||
(join ";" base-decls)
|
||||
(chunk-every media-rules 2)
|
||||
(chunk-every pseudo-rules 2)
|
||||
(chunk-every kf-needed 2))))
|
||||
media-rules
|
||||
pseudo-rules
|
||||
kf-needed)))
|
||||
(dict-set! _style-cache key sv)
|
||||
;; Inject CSS rules
|
||||
(inject-style-value sv atoms)
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
;; 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 | quote-sugar
|
||||
;; 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+)?
|
||||
@@ -15,316 +21,256 @@
|
||||
;; symbol → ident
|
||||
;; boolean → 'true' | 'false'
|
||||
;; nil → 'nil'
|
||||
;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]*
|
||||
;; ident → ident-start ident-char*
|
||||
;; comment → ';' to end of line (discarded)
|
||||
;;
|
||||
;; Dict literal:
|
||||
;; {key val ...} → dict object (keys are keywords or expressions)
|
||||
;;
|
||||
;; Quote sugar:
|
||||
;; `(expr) → (quasiquote expr)
|
||||
;; ,(expr) → (unquote expr)
|
||||
;; ,@(expr) → (splice-unquote expr)
|
||||
;; `expr → (quasiquote expr)
|
||||
;; ,expr → (unquote expr)
|
||||
;; ,@expr → (splice-unquote expr)
|
||||
;;
|
||||
;; 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
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tokenizer
|
||||
;; Parser — single-pass recursive descent
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Produces a flat stream of tokens from source text.
|
||||
;; Each token is a (type value line col) tuple.
|
||||
;; Returns a list of top-level AST expressions.
|
||||
|
||||
(define tokenize
|
||||
(define sx-parse
|
||||
(fn (source)
|
||||
(let ((pos 0)
|
||||
(line 1)
|
||||
(col 1)
|
||||
(tokens (list))
|
||||
(len-src (len source)))
|
||||
;; Main loop — bootstrap compilers convert to while
|
||||
(define scan-next
|
||||
|
||||
;; -- Cursor helpers (closure over pos, source, len-src) --
|
||||
|
||||
(define skip-comment
|
||||
(fn ()
|
||||
(when (and (< pos len-src) (not (= (nth source pos) "\n")))
|
||||
(set! pos (inc pos))
|
||||
(skip-comment))))
|
||||
|
||||
(define skip-ws
|
||||
(fn ()
|
||||
(when (< pos len-src)
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
;; Whitespace — skip
|
||||
(whitespace? ch)
|
||||
(do (advance-pos!) (scan-next))
|
||||
|
||||
;; Whitespace
|
||||
(or (= ch " ") (= ch "\t") (= ch "\n") (= ch "\r"))
|
||||
(do (set! pos (inc pos)) (skip-ws))
|
||||
;; Comment — skip to end of line
|
||||
(= ch ";")
|
||||
(do (skip-to-eol!) (scan-next))
|
||||
(do (set! pos (inc pos))
|
||||
(skip-comment)
|
||||
(skip-ws))
|
||||
;; Not whitespace or comment — stop
|
||||
:else nil)))))
|
||||
|
||||
;; -- Atom readers --
|
||||
|
||||
(define read-string
|
||||
(fn ()
|
||||
(set! pos (inc pos)) ;; skip opening "
|
||||
(let ((buf ""))
|
||||
(define read-str-loop
|
||||
(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)))
|
||||
(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
|
||||
(fn ()
|
||||
(let ((start pos))
|
||||
(define read-ident-loop
|
||||
(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
|
||||
(fn ()
|
||||
(set! pos (inc pos)) ;; skip :
|
||||
(make-keyword (read-ident))))
|
||||
|
||||
(define read-number
|
||||
(fn ()
|
||||
(let ((start pos))
|
||||
;; Optional leading minus
|
||||
(when (and (< pos len-src) (= (nth source pos) "-"))
|
||||
(set! pos (inc pos)))
|
||||
;; Integer digits
|
||||
(define read-digits
|
||||
(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
|
||||
(fn ()
|
||||
(let ((name (read-ident)))
|
||||
(cond
|
||||
(= name "true") true
|
||||
(= name "false") false
|
||||
(= name "nil") nil
|
||||
:else (make-symbol name)))))
|
||||
|
||||
;; -- Composite readers --
|
||||
|
||||
(define read-list
|
||||
(fn (close-ch)
|
||||
(let ((items (list)))
|
||||
(define read-list-loop
|
||||
(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
|
||||
(fn ()
|
||||
(let ((result (dict)))
|
||||
(define read-map-loop
|
||||
(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)))
|
||||
|
||||
;; -- Main expression reader --
|
||||
|
||||
(define read-expr
|
||||
(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 "\"")
|
||||
(do (append! tokens (scan-string)) (scan-next))
|
||||
|
||||
;; Open paren
|
||||
(= ch "(")
|
||||
(do (append! tokens (list "lparen" "(" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Close paren
|
||||
(= ch ")")
|
||||
(do (append! tokens (list "rparen" ")" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Open bracket (list sugar)
|
||||
(= ch "[")
|
||||
(do (append! tokens (list "lbracket" "[" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Close bracket
|
||||
(= ch "]")
|
||||
(do (append! tokens (list "rbracket" "]" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Open brace (dict literal)
|
||||
(= ch "{")
|
||||
(do (append! tokens (list "lbrace" "{" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Close brace
|
||||
(= ch "}")
|
||||
(do (append! tokens (list "rbrace" "}" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Quasiquote sugar
|
||||
(= ch "`")
|
||||
(do (advance-pos!)
|
||||
(let ((inner (scan-next-expr)))
|
||||
(append! tokens (list "quasiquote" inner line col))
|
||||
(scan-next)))
|
||||
|
||||
;; Unquote / splice-unquote
|
||||
(= ch ",")
|
||||
(do (advance-pos!)
|
||||
(if (and (< pos len-src) (= (nth source pos) "@"))
|
||||
(do (advance-pos!)
|
||||
(let ((inner (scan-next-expr)))
|
||||
(append! tokens (list "splice-unquote" inner line col))
|
||||
(scan-next)))
|
||||
(let ((inner (scan-next-expr)))
|
||||
(append! tokens (list "unquote" inner line col))
|
||||
(scan-next))))
|
||||
(read-string)
|
||||
|
||||
;; Keyword
|
||||
(= ch ":")
|
||||
(do (append! tokens (scan-keyword)) (scan-next))
|
||||
(read-keyword)
|
||||
|
||||
;; 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))))
|
||||
|
||||
;; Number (or negative number)
|
||||
(or (digit? ch)
|
||||
(and (= ch "-") (< (inc pos) len-src)
|
||||
(digit? (nth source (inc pos)))))
|
||||
(do (append! tokens (scan-number)) (scan-next))
|
||||
(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)
|
||||
|
||||
;; Symbol
|
||||
;; Symbol (must be ident-start char)
|
||||
(ident-start? ch)
|
||||
(do (append! tokens (scan-symbol)) (scan-next))
|
||||
(read-symbol)
|
||||
|
||||
;; Unknown — skip
|
||||
;; Unexpected
|
||||
:else
|
||||
(do (advance-pos!) (scan-next)))))))
|
||||
(scan-next)
|
||||
tokens)))
|
||||
(error (str "Unexpected character: " ch)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Token scanners (pseudo-code — each target implements natively)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define scan-string
|
||||
(fn ()
|
||||
;; Scan from opening " to closing ", handling escape sequences.
|
||||
;; Returns ("string" value line col).
|
||||
;; Escape sequences: \" \\ \n \t \r
|
||||
(let ((start-line line)
|
||||
(start-col col)
|
||||
(result ""))
|
||||
(advance-pos!) ;; skip opening "
|
||||
(define scan-str-loop
|
||||
(fn ()
|
||||
(if (>= pos (len source))
|
||||
(error "Unterminated string")
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
(= ch "\"")
|
||||
(do (advance-pos!) nil) ;; done
|
||||
(= ch "\\")
|
||||
(do (advance-pos!)
|
||||
(let ((esc (nth source pos)))
|
||||
(set! result (str result
|
||||
(case esc
|
||||
"n" "\n"
|
||||
"t" "\t"
|
||||
"r" "\r"
|
||||
:else esc)))
|
||||
(advance-pos!)
|
||||
(scan-str-loop)))
|
||||
:else
|
||||
(do (set! result (str result ch))
|
||||
(advance-pos!)
|
||||
(scan-str-loop)))))))
|
||||
(scan-str-loop)
|
||||
(list "string" result start-line start-col))))
|
||||
|
||||
|
||||
(define scan-keyword
|
||||
(fn ()
|
||||
;; Scan :identifier
|
||||
(let ((start-line line) (start-col col))
|
||||
(advance-pos!) ;; skip :
|
||||
(let ((name (scan-ident-chars)))
|
||||
(list "keyword" name start-line start-col)))))
|
||||
|
||||
|
||||
(define scan-number
|
||||
(fn ()
|
||||
;; Scan integer or float literal
|
||||
(let ((start-line line) (start-col col) (buf ""))
|
||||
(when (= (nth source pos) "-")
|
||||
(set! buf "-")
|
||||
(advance-pos!))
|
||||
;; Integer part
|
||||
(define scan-digits
|
||||
(fn ()
|
||||
(when (and (< pos (len source)) (digit? (nth source pos)))
|
||||
(set! buf (str buf (nth source pos)))
|
||||
(advance-pos!)
|
||||
(scan-digits))))
|
||||
(scan-digits)
|
||||
;; Decimal part
|
||||
(when (and (< pos (len source)) (= (nth source pos) "."))
|
||||
(set! buf (str buf "."))
|
||||
(advance-pos!)
|
||||
(scan-digits))
|
||||
;; Exponent
|
||||
(when (and (< pos (len source))
|
||||
(or (= (nth source pos) "e") (= (nth source pos) "E")))
|
||||
(set! buf (str buf (nth source pos)))
|
||||
(advance-pos!)
|
||||
(when (and (< pos (len source))
|
||||
(or (= (nth source pos) "+") (= (nth source pos) "-")))
|
||||
(set! buf (str buf (nth source pos)))
|
||||
(advance-pos!))
|
||||
(scan-digits))
|
||||
(list "number" (parse-number buf) start-line start-col))))
|
||||
|
||||
|
||||
(define scan-symbol
|
||||
(fn ()
|
||||
;; Scan identifier, check for true/false/nil
|
||||
(let ((start-line line)
|
||||
(start-col col)
|
||||
(name (scan-ident-chars)))
|
||||
(cond
|
||||
(= name "true") (list "boolean" true start-line start-col)
|
||||
(= name "false") (list "boolean" false start-line start-col)
|
||||
(= name "nil") (list "nil" nil start-line start-col)
|
||||
:else (list "symbol" name start-line start-col)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Parser — tokens → AST
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse
|
||||
(fn (tokens)
|
||||
;; Parse all top-level expressions from token stream.
|
||||
(let ((pos 0)
|
||||
(exprs (list)))
|
||||
(define parse-loop
|
||||
(fn ()
|
||||
(when (< pos (len tokens))
|
||||
(let ((result (parse-expr tokens)))
|
||||
(append! exprs result)
|
||||
(parse-loop)))))
|
||||
(parse-loop)
|
||||
exprs)))
|
||||
|
||||
|
||||
(define parse-expr
|
||||
(fn (tokens)
|
||||
;; Parse a single expression.
|
||||
(let ((tok (nth tokens pos)))
|
||||
(case (first tok) ;; token type
|
||||
"lparen"
|
||||
(do (set! pos (inc pos))
|
||||
(parse-list tokens "rparen"))
|
||||
|
||||
"lbracket"
|
||||
(do (set! pos (inc pos))
|
||||
(parse-list tokens "rbracket"))
|
||||
|
||||
"lbrace"
|
||||
(do (set! pos (inc pos))
|
||||
(parse-dict tokens))
|
||||
|
||||
"string" (do (set! pos (inc pos)) (nth tok 1))
|
||||
"number" (do (set! pos (inc pos)) (nth tok 1))
|
||||
"boolean" (do (set! pos (inc pos)) (nth tok 1))
|
||||
"nil" (do (set! pos (inc pos)) nil)
|
||||
|
||||
"keyword"
|
||||
(do (set! pos (inc pos))
|
||||
(make-keyword (nth tok 1)))
|
||||
|
||||
"symbol"
|
||||
(do (set! pos (inc pos))
|
||||
(make-symbol (nth tok 1)))
|
||||
|
||||
:else (error (str "Unexpected token: " (inspect tok)))))))
|
||||
|
||||
|
||||
(define parse-list
|
||||
(fn (tokens close-type)
|
||||
;; Parse expressions until close-type token.
|
||||
(let ((items (list)))
|
||||
(define parse-list-loop
|
||||
(fn ()
|
||||
(if (>= pos (len tokens))
|
||||
(error "Unterminated list")
|
||||
(if (= (first (nth tokens pos)) close-type)
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(do (append! items (parse-expr tokens))
|
||||
(parse-list-loop))))))
|
||||
(parse-list-loop)
|
||||
items)))
|
||||
|
||||
|
||||
(define parse-dict
|
||||
(fn (tokens)
|
||||
;; Parse {key val key val ...} until "rbrace" token.
|
||||
;; Returns a dict (plain object).
|
||||
(let ((result (dict)))
|
||||
(define parse-dict-loop
|
||||
(fn ()
|
||||
(if (>= pos (len tokens))
|
||||
(error "Unterminated dict")
|
||||
(if (= (first (nth tokens pos)) "rbrace")
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(let ((key-expr (parse-expr tokens))
|
||||
(key-str (if (= (type-of key-expr) "keyword")
|
||||
(keyword-name key-expr)
|
||||
(str key-expr)))
|
||||
(val-expr (parse-expr tokens)))
|
||||
(dict-set! result key-str val-expr)
|
||||
(parse-dict-loop))))))
|
||||
(parse-dict-loop)
|
||||
result)))
|
||||
;; -- Entry point: parse all top-level expressions --
|
||||
(let ((exprs (list)))
|
||||
(define parse-loop
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(when (< pos len-src)
|
||||
(append! exprs (read-expr))
|
||||
(parse-loop))))
|
||||
(parse-loop)
|
||||
exprs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Serializer — AST → SX source text
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define serialize
|
||||
(define sx-serialize
|
||||
(fn (val)
|
||||
(case (type-of val)
|
||||
"nil" "nil"
|
||||
@@ -333,46 +279,41 @@
|
||||
"string" (str "\"" (escape-string val) "\"")
|
||||
"symbol" (symbol-name val)
|
||||
"keyword" (str ":" (keyword-name val))
|
||||
"list" (str "(" (join " " (map serialize val)) ")")
|
||||
"dict" (serialize-dict val)
|
||||
"list" (str "(" (join " " (map sx-serialize val)) ")")
|
||||
"dict" (sx-serialize-dict val)
|
||||
"sx-expr" (sx-expr-source val)
|
||||
:else (str val))))
|
||||
|
||||
|
||||
(define serialize-dict
|
||||
(define sx-serialize-dict
|
||||
(fn (d)
|
||||
(str "(dict "
|
||||
(str "{"
|
||||
(join " "
|
||||
(reduce
|
||||
(fn (acc key)
|
||||
(concat acc (list (str ":" key) (serialize (dict-get d key)))))
|
||||
(concat acc (list (str ":" key) (sx-serialize (dict-get d key)))))
|
||||
(list)
|
||||
(keys d)))
|
||||
")")))
|
||||
"}")))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform parser interface
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Character classification:
|
||||
;; (whitespace? ch) → boolean
|
||||
;; (digit? ch) → boolean
|
||||
;; (ident-start? ch) → boolean (letter, _, ~, *, +, -, etc.)
|
||||
;; (ident-char? ch) → boolean (ident-start + digits, ., :)
|
||||
;; Character classification (implemented natively per target):
|
||||
;; (ident-start? ch) → boolean
|
||||
;; True for: a-z A-Z _ ~ * + - > < = / ! ? &
|
||||
;;
|
||||
;; Constructors:
|
||||
;; (make-symbol name) → Symbol value
|
||||
;; (make-keyword name) → Keyword value
|
||||
;; (parse-number s) → number (int or float from string)
|
||||
;; (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
|
||||
;;
|
||||
;; Cursor state (mutable — each target manages its own way):
|
||||
;; pos, line, col — current position in source
|
||||
;; (advance-pos!) → increment pos, update line/col
|
||||
;; (skip-to-eol!) → advance past end of line
|
||||
;; (scan-ident-chars) → consume and return identifier string
|
||||
;; (escape-string s) → string with " and \ escaped
|
||||
;; (sx-expr-source e) → unwrap SxExpr to its source string
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -443,6 +443,16 @@
|
||||
:doc "Split comma-separated ID string into list of trimmed non-empty strings.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Assertions
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-primitive "assert"
|
||||
:params (condition &rest message)
|
||||
:returns "boolean"
|
||||
:doc "Assert condition is truthy; raise error with message if not.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; CSSX — style system primitives
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user