From 8a530569a2d1e33ca51e717ae66eba99e70e0004 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 11 Mar 2026 17:12:54 +0000 Subject: [PATCH] Add (name :as type) annotation syntax for defcomp params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse-comp-params now recognizes (name :as type) — a 3-element list with :as keyword separator. Type annotations are stored on the Component via component-param-types and used by types.sx for call-site checking. Unannotated params default to any. 428/428 tests pass (50 types tests including 6 annotation tests). Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 22 ++++++++--- shared/sx/ref/bootstrap_py.py | 2 + shared/sx/ref/eval.sx | 47 +++++++++++++++++------ shared/sx/ref/sx_ref.py | 48 ++++++++++++++++------- shared/sx/ref/test-types.sx | 59 +++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 31 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index a5cf2ed..20883b4 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-11T16:56:11Z"; + var SX_VERSION = "2026-03-11T17:12:38Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -904,9 +904,13 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var parsed = parseCompParams(paramsRaw); var params = first(parsed); var hasChildren = nth(parsed, 1); + var paramTypes = nth(parsed, 2); var affinity = defcompKwarg(args, "affinity", "auto"); return (function() { var comp = makeComponent(compName, params, hasChildren, body, env, affinity); + if (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(paramTypes))) && !isSxTruthy(isEmpty(keys(paramTypes)))))) { + componentSetParamTypes_b(comp, paramTypes); +} envSet(env, symbolName(nameSym), comp); return comp; })(); @@ -928,15 +932,21 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai // parse-comp-params var parseCompParams = function(paramsExpr) { return (function() { var params = []; + var paramTypes = {}; var hasChildren = false; var inKey = false; - { var _c = paramsExpr; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; if (isSxTruthy((typeOf(p) == "symbol"))) { - (function() { + { var _c = paramsExpr; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; (isSxTruthy((isSxTruthy((typeOf(p) == "list")) && isSxTruthy((len(p) == 3)) && isSxTruthy((typeOf(first(p)) == "symbol")) && isSxTruthy((typeOf(nth(p, 1)) == "keyword")) && (keywordName(nth(p, 1)) == "as"))) ? (function() { + var name = symbolName(first(p)); + var ptype = nth(p, 2); + return (function() { + var typeVal = (isSxTruthy((typeOf(ptype) == "symbol")) ? symbolName(ptype) : ptype); + return (isSxTruthy(!isSxTruthy(hasChildren)) ? (append_b(params, name), dictSet(paramTypes, name, typeVal)) : NIL); +})(); +})() : (isSxTruthy((typeOf(p) == "symbol")) ? (function() { var name = symbolName(p); return (isSxTruthy((name == "&key")) ? (inKey = true) : (isSxTruthy((name == "&rest")) ? (hasChildren = true) : (isSxTruthy((name == "&children")) ? (hasChildren = true) : (isSxTruthy(hasChildren) ? NIL : (isSxTruthy(inKey) ? append_b(params, name) : append_b(params, name)))))); -})(); -} } } - return [params, hasChildren]; +})() : NIL)); } } + return [params, hasChildren, paramTypes]; })(); }; // sf-defisland diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 4cd506b..abfd9e8 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -142,6 +142,8 @@ class PyEmitter: "component-has-children?": "component_has_children", "component-name": "component_name", "component-affinity": "component_affinity", + "component-param-types": "component_param_types", + "component-set-param-types!": "component_set_param_types", "macro-params": "macro_params", "macro-rest-param": "macro_rest_param", "macro-body": "macro_body", diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx index b8ea21f..5d44a33 100644 --- a/shared/sx/ref/eval.sx +++ b/shared/sx/ref/eval.sx @@ -518,8 +518,13 @@ (parsed (parse-comp-params params-raw)) (params (first parsed)) (has-children (nth parsed 1)) + (param-types (nth parsed 2)) (affinity (defcomp-kwarg args "affinity" "auto"))) (let ((comp (make-component comp-name params has-children body env affinity))) + ;; Store type annotations if any were declared + (when (and (not (nil? param-types)) + (not (empty? (keys param-types)))) + (component-set-param-types! comp param-types)) (env-set! env (symbol-name name-sym) comp) comp)))) @@ -541,24 +546,44 @@ (define parse-comp-params (fn (params-expr) - ;; Parse (&key param1 param2 &children) → (params has-children) + ;; Parse (&key param1 param2 &children) → (params has-children param-types) ;; Also accepts &rest as synonym for &children. + ;; Supports typed params: (name :as type) — a 3-element list where + ;; the second element is the keyword :as. Unannotated params get no + ;; type entry. param-types is a dict {name → type-expr} or empty dict. (let ((params (list)) + (param-types (dict)) (has-children false) (in-key false)) (for-each (fn (p) - (when (= (type-of p) "symbol") - (let ((name (symbol-name p))) - (cond - (= name "&key") (set! in-key true) - (= name "&rest") (set! has-children true) - (= name "&children") (set! has-children true) - has-children nil ;; skip params after &children/&rest - in-key (append! params name) - :else (append! params name))))) + (if (and (= (type-of p) "list") + (= (len p) 3) + (= (type-of (first p)) "symbol") + (= (type-of (nth p 1)) "keyword") + (= (keyword-name (nth p 1)) "as")) + ;; Typed param: (name :as type) + (let ((name (symbol-name (first p))) + (ptype (nth p 2))) + ;; Convert type to string if it's a symbol + (let ((type-val (if (= (type-of ptype) "symbol") + (symbol-name ptype) + ptype))) + (when (not has-children) + (append! params name) + (dict-set! param-types name type-val)))) + ;; Untyped param or marker + (when (= (type-of p) "symbol") + (let ((name (symbol-name p))) + (cond + (= name "&key") (set! in-key true) + (= name "&rest") (set! has-children true) + (= name "&children") (set! has-children true) + has-children nil ;; skip params after &children/&rest + in-key (append! params name) + :else (append! params name)))))) params-expr) - (list params has-children)))) + (list params has-children param-types)))) (define sf-defisland diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index d937b2d..8cbff3b 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -266,6 +266,14 @@ def component_affinity(c): return getattr(c, 'affinity', 'auto') +def component_param_types(c): + return getattr(c, 'param_types', None) + + +def component_set_param_types(c, d): + c.param_types = d + + def macro_params(m): return m.params @@ -1510,8 +1518,11 @@ def sf_defcomp(args, env): parsed = parse_comp_params(params_raw) params = first(parsed) has_children = nth(parsed, 1) + param_types = nth(parsed, 2) affinity = defcomp_kwarg(args, 'affinity', 'auto') comp = make_component(comp_name, params, has_children, body, env, affinity) + if sx_truthy(((not sx_truthy(is_nil(param_types))) if not sx_truthy((not sx_truthy(is_nil(param_types)))) else (not sx_truthy(empty_p(keys(param_types)))))): + component_set_param_types(comp, param_types) env[symbol_name(name_sym)] = comp return comp @@ -1530,24 +1541,33 @@ def defcomp_kwarg(args, key, default_): def parse_comp_params(params_expr): _cells = {} params = [] + param_types = {} _cells['has_children'] = False _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((name == '&rest')): - _cells['has_children'] = True - elif sx_truthy((name == '&children')): - _cells['has_children'] = True - elif sx_truthy(_cells['has_children']): - NIL - elif sx_truthy(_cells['in_key']): + if sx_truthy(((type_of(p) == 'list') if not sx_truthy((type_of(p) == 'list')) else ((len(p) == 3) if not sx_truthy((len(p) == 3)) else ((type_of(first(p)) == 'symbol') if not sx_truthy((type_of(first(p)) == 'symbol')) else ((type_of(nth(p, 1)) == 'keyword') if not sx_truthy((type_of(nth(p, 1)) == 'keyword')) else (keyword_name(nth(p, 1)) == 'as')))))): + name = symbol_name(first(p)) + ptype = nth(p, 2) + type_val = (symbol_name(ptype) if sx_truthy((type_of(ptype) == 'symbol')) else ptype) + if sx_truthy((not sx_truthy(_cells['has_children']))): params.append(name) - else: - params.append(name) - return [params, _cells['has_children']] + param_types[name] = type_val + else: + if sx_truthy((type_of(p) == 'symbol')): + name = symbol_name(p) + if sx_truthy((name == '&key')): + _cells['in_key'] = True + elif sx_truthy((name == '&rest')): + _cells['has_children'] = True + elif sx_truthy((name == '&children')): + _cells['has_children'] = True + elif sx_truthy(_cells['has_children']): + NIL + elif sx_truthy(_cells['in_key']): + params.append(name) + else: + params.append(name) + return [params, _cells['has_children'], param_types] # sf-defisland def sf_defisland(args, env): diff --git a/shared/sx/ref/test-types.sx b/shared/sx/ref/test-types.sx index b16e5ac..57fcae7 100644 --- a/shared/sx/ref/test-types.sx +++ b/shared/sx/ref/test-types.sx @@ -283,3 +283,62 @@ (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))))))