diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index d47f8f6..87ecba2 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -106,6 +106,10 @@ class PyEmitter: "make-component": "make_component", "make-macro": "make_macro", "make-thunk": "make_thunk", + "make-handler-def": "make_handler_def", + "make-query-def": "make_query_def", + "make-action-def": "make_action_def", + "make-page-def": "make_page_def", "make-symbol": "make_symbol", "make-keyword": "make_keyword", "lambda-params": "lambda_params", @@ -821,6 +825,7 @@ def compile_ref_to_py(adapters: list[str] | None = None) -> str: # Core files always included, then selected adapters sx_files = [ ("eval.sx", "eval"), + ("forms.sx", "forms (server definition forms)"), ("render.sx", "render (core)"), ] for name in ("html", "sx"): @@ -883,6 +888,7 @@ from typing import Any from shared.sx.types import ( NIL, Symbol, Keyword, Lambda, Component, Macro, StyleValue, + HandlerDef, QueryDef, ActionDef, PageDef, ) from shared.sx.parser import SxExpr ''' @@ -1029,6 +1035,39 @@ def make_macro(params, rest_param, body, env, name=None): closure=dict(env), name=name) +def make_handler_def(name, params, body, env): + return HandlerDef(name=name, params=list(params), body=body, closure=dict(env)) + + +def make_query_def(name, params, doc, body, env): + return QueryDef(name=name, params=list(params), doc=doc, body=body, closure=dict(env)) + + +def make_action_def(name, params, doc, body, env): + return ActionDef(name=name, params=list(params), doc=doc, body=body, closure=dict(env)) + + +def make_page_def(name, slots, env): + path = slots.get("path", "") + auth_val = slots.get("auth", "public") + if isinstance(auth_val, Keyword): + auth = auth_val.name + elif isinstance(auth_val, list): + auth = [item.name if isinstance(item, Keyword) else str(item) for item in auth_val] + else: + auth = str(auth_val) if auth_val else "public" + layout = slots.get("layout") + if isinstance(layout, Keyword): + layout = layout.name + cache = None + return PageDef( + name=name, path=path, auth=auth, layout=layout, cache=cache, + data_expr=slots.get("data"), content_expr=slots.get("content"), + filter_expr=slots.get("filter"), aside_expr=slots.get("aside"), + menu_expr=slots.get("menu"), closure=dict(env), + ) + + def make_thunk(expr, env): return _Thunk(expr, env) diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx index eb4134f..ccc1a8d 100644 --- a/shared/sx/ref/eval.sx +++ b/shared/sx/ref/eval.sx @@ -143,7 +143,10 @@ (= name "defmacro") (sf-defmacro args env) (= name "defstyle") (sf-defstyle args env) (= name "defkeyframes") (sf-defkeyframes args env) - (= name "defhandler") (sf-define args env) + (= name "defhandler") (sf-defhandler args env) + (= name "defpage") (sf-defpage args env) + (= name "defquery") (sf-defquery args env) + (= name "defaction") (sf-defaction args env) (= name "begin") (sf-begin args env) (= name "do") (sf-begin args env) (= name "quote") (sf-quote args env) diff --git a/shared/sx/ref/forms.sx b/shared/sx/ref/forms.sx new file mode 100644 index 0000000..2acb57e --- /dev/null +++ b/shared/sx/ref/forms.sx @@ -0,0 +1,118 @@ +;; ========================================================================== +;; 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) + (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 (&key param...) body) +;; -------------------------------------------------------------------------- + +(define sf-defhandler + (fn (args env) + (let ((name-sym (first args)) + (params-raw (nth args 1)) + (body (nth args 2)) + (name (symbol-name name-sym)) + (params (parse-key-params params-raw))) + (let ((hdef (make-handler-def name params body env))) + (env-set! env (str "handler:" name) hdef) + hdef)))) + + +;; -------------------------------------------------------------------------- +;; defquery — (defquery name (&key param...) "docstring" body) +;; -------------------------------------------------------------------------- + +(define sf-defquery + (fn (args env) + (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 env) + (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 env) + (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) + (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)))) diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 498fb4a..dfac72f 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -18,6 +18,7 @@ from typing import Any from shared.sx.types import ( NIL, Symbol, Keyword, Lambda, Component, Macro, StyleValue, + HandlerDef, QueryDef, ActionDef, PageDef, ) from shared.sx.parser import SxExpr @@ -163,6 +164,39 @@ def make_macro(params, rest_param, body, env, name=None): closure=dict(env), name=name) +def make_handler_def(name, params, body, env): + return HandlerDef(name=name, params=list(params), body=body, closure=dict(env)) + + +def make_query_def(name, params, doc, body, env): + return QueryDef(name=name, params=list(params), doc=doc, body=body, closure=dict(env)) + + +def make_action_def(name, params, doc, body, env): + return ActionDef(name=name, params=list(params), doc=doc, body=body, closure=dict(env)) + + +def make_page_def(name, slots, env): + path = slots.get("path", "") + auth_val = slots.get("auth", "public") + if isinstance(auth_val, Keyword): + auth = auth_val.name + elif isinstance(auth_val, list): + auth = [item.name if isinstance(item, Keyword) else str(item) for item in auth_val] + else: + auth = str(auth_val) if auth_val else "public" + layout = slots.get("layout") + if isinstance(layout, Keyword): + layout = layout.name + cache = None + return PageDef( + name=name, path=path, auth=auth, layout=layout, cache=cache, + data_expr=slots.get("data"), content_expr=slots.get("content"), + filter_expr=slots.get("filter"), aside_expr=slots.get("aside"), + menu_expr=slots.get("menu"), closure=dict(env), + ) + + def make_thunk(expr, env): return _Thunk(expr, env) @@ -820,7 +854,7 @@ trampoline = lambda val: (lambda result: (trampoline(eval_expr(thunk_expr(result eval_expr = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('dict', lambda: map_dict(lambda k, v: trampoline(eval_expr(v, env)), expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else eval_list(expr, env))), (None, lambda: expr)]) # eval-list -eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defkeyframes(args, env) if sx_truthy((name == 'defkeyframes')) else (sf_define(args, env) if sx_truthy((name == 'defhandler')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env)))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr)) +eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defkeyframes(args, env) if sx_truthy((name == 'defkeyframes')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr)) # eval-call eval_call = lambda head, args, env: (lambda f: (lambda evaluated_args: (apply(f, evaluated_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else (not sx_truthy(is_component(f)))))) else (call_lambda(f, evaluated_args, env) if sx_truthy(is_lambda(f)) else (call_component(f, args, env) if sx_truthy(is_component(f)) else error(sx_str('Not callable: ', inspect(f)))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env))) @@ -956,6 +990,37 @@ ho_every = lambda args, env: (lambda f: (lambda coll: every_p(lambda item: tramp ho_for_each = lambda args, env: (lambda f: (lambda coll: for_each(lambda item: trampoline(call_lambda(f, [item], env)), coll))(trampoline(eval_expr(nth(args, 1), env))))(trampoline(eval_expr(first(args), env))) +# === Transpiled from forms (server definition forms) === + +# parse-key-params +def parse_key_params(params_expr): + _cells = {} + params = [] + _cells['in_key'] = False + for p in params_expr: + if sx_truthy((type_of(p) == 'symbol')): + name = symbol_name(p) + if sx_truthy((name == '&key')): + _cells['in_key'] = True + elif sx_truthy(_cells['in_key']): + params.append(name) + else: + params.append(name) + return params + +# sf-defhandler +sf_defhandler = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda name: (lambda params: (lambda hdef: _sx_begin(_sx_dict_set(env, sx_str('handler:', name), hdef), hdef))(make_handler_def(name, params, body, env)))(parse_key_params(params_raw)))(symbol_name(name_sym)))(nth(args, 2)))(nth(args, 1)))(first(args)) + +# sf-defquery +sf_defquery = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda name: (lambda params: (lambda has_doc: (lambda doc: (lambda body: (lambda qdef: _sx_begin(_sx_dict_set(env, sx_str('query:', name), qdef), qdef))(make_query_def(name, params, doc, body, env)))((nth(args, 3) if sx_truthy(has_doc) else nth(args, 2))))((nth(args, 2) if sx_truthy(has_doc) else '')))(((len(args) >= 4) if not sx_truthy((len(args) >= 4)) else (type_of(nth(args, 2)) == 'string'))))(parse_key_params(params_raw)))(symbol_name(name_sym)))(nth(args, 1)))(first(args)) + +# sf-defaction +sf_defaction = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda name: (lambda params: (lambda has_doc: (lambda doc: (lambda body: (lambda adef: _sx_begin(_sx_dict_set(env, sx_str('action:', name), adef), adef))(make_action_def(name, params, doc, body, env)))((nth(args, 3) if sx_truthy(has_doc) else nth(args, 2))))((nth(args, 2) if sx_truthy(has_doc) else '')))(((len(args) >= 4) if not sx_truthy((len(args) >= 4)) else (type_of(nth(args, 2)) == 'string'))))(parse_key_params(params_raw)))(symbol_name(name_sym)))(nth(args, 1)))(first(args)) + +# sf-defpage +sf_defpage = lambda args, env: (lambda name_sym: (lambda name: (lambda slots: _sx_begin((lambda i: (lambda max_i: for_each(lambda idx: ((_sx_dict_set(slots, keyword_name(nth(args, idx)), nth(args, (idx + 1))) if sx_truthy(((idx + 1) < max_i)) else NIL) if sx_truthy(((idx < max_i) if not sx_truthy((idx < max_i)) else (type_of(nth(args, idx)) == 'keyword'))) else NIL), range(1, max_i, 2)))(len(args)))(1), (lambda pdef: _sx_begin(_sx_dict_set(env, sx_str('page:', name), pdef), pdef))(make_page_def(name, slots, env))))({}))(symbol_name(name_sym)))(first(args)) + + # === Transpiled from render (core) === # HTML_TAGS diff --git a/shared/sx/tests/test_sx_ref.py b/shared/sx/tests/test_sx_ref.py index ad8dc46..ae21015 100644 --- a/shared/sx/tests/test_sx_ref.py +++ b/shared/sx/tests/test_sx_ref.py @@ -328,6 +328,62 @@ class TestAser: # Macros # --------------------------------------------------------------------------- +class TestDefcomp: + def test_defcomp_basic(self): + """defcomp should parse &key params without trying to eval &key as a symbol.""" + env = {} + ev('(defcomp ~card (&key title) (div title))', env) + assert isinstance(env.get("~card"), Component) + + def test_defcomp_render(self): + env = {} + ev('(defcomp ~card (&key title) (div title))', env) + result = render('(~card :title "hello")', env) + assert result == '
hello
' + + def test_defcomp_with_children(self): + env = {} + ev('(defcomp ~wrap (&key title &rest children) (div title children))', env) + comp = env["~wrap"] + assert comp.has_children is True + assert "title" in comp.params + + def test_defcomp_multiple_params(self): + env = {} + ev('(defcomp ~box (&key a b c) (div a b c))', env) + result = render('(~box :a "1" :b "2" :c "3")', env) + assert result == '
123
' + + +class TestDefhandler: + def test_defhandler_basic(self): + """defhandler should parse &key params and create a HandlerDef.""" + from shared.sx.types import HandlerDef + env = {} + ev('(defhandler link-card (&key slug keys) (div slug))', env) + hdef = env.get("handler:link-card") + assert isinstance(hdef, HandlerDef) + assert hdef.name == "link-card" + assert hdef.params == ["slug", "keys"] + + def test_defquery_basic(self): + from shared.sx.types import QueryDef + env = {} + ev('(defquery get-post (&key slug) "Fetch a post" (list slug))', env) + qdef = env.get("query:get-post") + assert isinstance(qdef, QueryDef) + assert qdef.params == ["slug"] + assert qdef.doc == "Fetch a post" + + def test_defaction_basic(self): + from shared.sx.types import ActionDef + env = {} + ev('(defaction save-post (&key title body) (list title body))', env) + adef = env.get("action:save-post") + assert isinstance(adef, ActionDef) + assert adef.params == ["title", "body"] + + class TestMacros: def test_defmacro_and_expand(self): env = {}