diff --git a/shared/sx/ref/platform_py.py b/shared/sx/ref/platform_py.py index c21be75..b1d1d65 100644 --- a/shared/sx/ref/platform_py.py +++ b/shared/sx/ref/platform_py.py @@ -307,6 +307,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 @@ -1428,6 +1436,7 @@ SPEC_MODULES = { "router": ("router.sx", "router (client-side route matching)"), "engine": ("engine.sx", "engine (fetch/swap/trigger pure logic)"), "signals": ("signals.sx", "signals (reactive signal runtime)"), + "types": ("types.sx", "types (gradual type system)"), } EXTENSION_NAMES = {"continuations"} diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 427dced..5cd4025 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 @@ -2342,454 +2350,6 @@ def env_components(env): return filter(lambda k: (lambda v: (is_component(v) if sx_truthy(is_component(v)) else is_macro(v)))(env_get(env, k)), keys(env)) -# === Transpiled from engine (fetch/swap/trigger pure logic) === - -# ENGINE_VERBS -ENGINE_VERBS = ['get', 'post', 'put', 'delete', 'patch'] - -# DEFAULT_SWAP -DEFAULT_SWAP = 'outerHTML' - -# parse-time -def parse_time(s): - if sx_truthy(is_nil(s)): - return 0 - elif sx_truthy(ends_with_p(s, 'ms')): - return parse_int(s, 0) - elif sx_truthy(ends_with_p(s, 's')): - return (parse_int(replace(s, 's', ''), 0) * 1000) - else: - return parse_int(s, 0) - -# parse-trigger-spec -def parse_trigger_spec(spec): - if sx_truthy(is_nil(spec)): - return NIL - else: - raw_parts = split(spec, ',') - return filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda part: (lambda tokens: (NIL if sx_truthy(empty_p(tokens)) else ({'event': 'every', 'modifiers': {'interval': parse_time(nth(tokens, 1))}} if sx_truthy(((first(tokens) == 'every') if not sx_truthy((first(tokens) == 'every')) else (len(tokens) >= 2))) else (lambda mods: _sx_begin(for_each(lambda tok: (_sx_dict_set(mods, 'once', True) if sx_truthy((tok == 'once')) else (_sx_dict_set(mods, 'changed', True) if sx_truthy((tok == 'changed')) else (_sx_dict_set(mods, 'delay', parse_time(slice(tok, 6))) if sx_truthy(starts_with_p(tok, 'delay:')) else (_sx_dict_set(mods, 'from', slice(tok, 5)) if sx_truthy(starts_with_p(tok, 'from:')) else NIL)))), rest(tokens)), {'event': first(tokens), 'modifiers': mods}))({}))))(split(trim(part), ' ')), raw_parts)) - -# default-trigger -def default_trigger(tag_name): - if sx_truthy((tag_name == 'FORM')): - return [{'event': 'submit', 'modifiers': {}}] - elif sx_truthy(((tag_name == 'INPUT') if sx_truthy((tag_name == 'INPUT')) else ((tag_name == 'SELECT') if sx_truthy((tag_name == 'SELECT')) else (tag_name == 'TEXTAREA')))): - return [{'event': 'change', 'modifiers': {}}] - else: - return [{'event': 'click', 'modifiers': {}}] - -# get-verb-info -def get_verb_info(el): - return some(lambda verb: (lambda url: ({'method': upper(verb), 'url': url} if sx_truthy(url) else NIL))(dom_get_attr(el, sx_str('sx-', verb))), ENGINE_VERBS) - -# build-request-headers -def build_request_headers(el, loaded_components, css_hash): - headers = {'SX-Request': 'true', 'SX-Current-URL': browser_location_href()} - target_sel = dom_get_attr(el, 'sx-target') - if sx_truthy(target_sel): - headers['SX-Target'] = target_sel - if sx_truthy((not sx_truthy(empty_p(loaded_components)))): - headers['SX-Components'] = join(',', loaded_components) - if sx_truthy(css_hash): - headers['SX-Css'] = css_hash - extra_h = dom_get_attr(el, 'sx-headers') - if sx_truthy(extra_h): - parsed = parse_header_value(extra_h) - if sx_truthy(parsed): - for key in keys(parsed): - headers[key] = sx_str(get(parsed, key)) - return headers - -# process-response-headers -def process_response_headers(get_header): - return {'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')} - -# parse-swap-spec -def parse_swap_spec(raw_swap, global_transitions_p): - _cells = {} - parts = split((raw_swap if sx_truthy(raw_swap) else DEFAULT_SWAP), ' ') - style = first(parts) - _cells['use_transition'] = global_transitions_p - for p in rest(parts): - if sx_truthy((p == 'transition:true')): - _cells['use_transition'] = True - elif sx_truthy((p == 'transition:false')): - _cells['use_transition'] = False - return {'style': style, 'transition': _cells['use_transition']} - -# parse-retry-spec -def parse_retry_spec(retry_attr): - if sx_truthy(is_nil(retry_attr)): - return NIL - else: - parts = split(retry_attr, ':') - return {'strategy': first(parts), 'start-ms': parse_int(nth(parts, 1), 1000), 'cap-ms': parse_int(nth(parts, 2), 30000)} - -# next-retry-ms -def next_retry_ms(current_ms, cap_ms): - return min((current_ms * 2), cap_ms) - -# filter-params -def filter_params(params_spec, all_params): - if sx_truthy(is_nil(params_spec)): - return all_params - elif sx_truthy((params_spec == 'none')): - return [] - elif sx_truthy((params_spec == '*')): - return all_params - elif sx_truthy(starts_with_p(params_spec, 'not ')): - excluded = map(trim, split(slice(params_spec, 4), ',')) - return filter(lambda p: (not sx_truthy(contains_p(excluded, first(p)))), all_params) - else: - allowed = map(trim, split(params_spec, ',')) - return filter(lambda p: contains_p(allowed, first(p)), all_params) - -# resolve-target -def resolve_target(el): - sel = dom_get_attr(el, 'sx-target') - if sx_truthy((is_nil(sel) if sx_truthy(is_nil(sel)) else (sel == 'this'))): - return el - elif sx_truthy((sel == 'closest')): - return dom_parent(el) - else: - return dom_query(sel) - -# apply-optimistic -def apply_optimistic(el): - directive = dom_get_attr(el, 'sx-optimistic') - if sx_truthy(is_nil(directive)): - return NIL - else: - target = (resolve_target(el) if sx_truthy(resolve_target(el)) else el) - state = {'target': target, 'directive': directive} - (_sx_begin(_sx_dict_set(state, 'opacity', dom_get_style(target, 'opacity')), dom_set_style(target, 'opacity', '0'), dom_set_style(target, 'pointer-events', 'none')) if sx_truthy((directive == 'remove')) else (_sx_begin(_sx_dict_set(state, 'disabled', dom_get_prop(target, 'disabled')), dom_set_prop(target, 'disabled', True)) if sx_truthy((directive == 'disable')) else ((lambda cls: _sx_begin(_sx_dict_set(state, 'add-class', cls), dom_add_class(target, cls)))(slice(directive, 10)) if sx_truthy(starts_with_p(directive, 'add-class:')) else NIL))) - return state - -# revert-optimistic -def revert_optimistic(state): - if sx_truthy(state): - target = get(state, 'target') - directive = get(state, 'directive') - if sx_truthy((directive == 'remove')): - dom_set_style(target, 'opacity', (get(state, 'opacity') if sx_truthy(get(state, 'opacity')) else '')) - return dom_set_style(target, 'pointer-events', '') - elif sx_truthy((directive == 'disable')): - return dom_set_prop(target, 'disabled', (get(state, 'disabled') if sx_truthy(get(state, 'disabled')) else False)) - elif sx_truthy(get(state, 'add-class')): - return dom_remove_class(target, get(state, 'add-class')) - return NIL - return NIL - -# find-oob-swaps -def find_oob_swaps(container): - results = [] - for attr in ['sx-swap-oob', 'hx-swap-oob']: - oob_els = dom_query_all(container, sx_str('[', attr, ']')) - for oob in oob_els: - swap_type = (dom_get_attr(oob, attr) if sx_truthy(dom_get_attr(oob, attr)) else 'outerHTML') - target_id = dom_id(oob) - dom_remove_attr(oob, attr) - if sx_truthy(target_id): - results.append({'element': oob, 'swap-type': swap_type, 'target-id': target_id}) - return results - -# morph-node -def morph_node(old_node, new_node): - if sx_truthy((dom_has_attr_p(old_node, 'sx-preserve') if sx_truthy(dom_has_attr_p(old_node, 'sx-preserve')) else dom_has_attr_p(old_node, 'sx-ignore'))): - return NIL - elif sx_truthy((dom_has_attr_p(old_node, 'data-sx-island') if not sx_truthy(dom_has_attr_p(old_node, 'data-sx-island')) else (is_processed_p(old_node, 'island-hydrated') if not sx_truthy(is_processed_p(old_node, 'island-hydrated')) else (dom_has_attr_p(new_node, 'data-sx-island') if not sx_truthy(dom_has_attr_p(new_node, 'data-sx-island')) else (dom_get_attr(old_node, 'data-sx-island') == dom_get_attr(new_node, 'data-sx-island')))))): - return morph_island_children(old_node, new_node) - elif sx_truthy(((not sx_truthy((dom_node_type(old_node) == dom_node_type(new_node)))) if sx_truthy((not sx_truthy((dom_node_type(old_node) == dom_node_type(new_node))))) else (not sx_truthy((dom_node_name(old_node) == dom_node_name(new_node)))))): - return dom_replace_child(dom_parent(old_node), dom_clone(new_node), old_node) - elif sx_truthy(((dom_node_type(old_node) == 3) if sx_truthy((dom_node_type(old_node) == 3)) else (dom_node_type(old_node) == 8))): - if sx_truthy((not sx_truthy((dom_text_content(old_node) == dom_text_content(new_node))))): - return dom_set_text_content(old_node, dom_text_content(new_node)) - return NIL - elif sx_truthy((dom_node_type(old_node) == 1)): - sync_attrs(old_node, new_node) - if sx_truthy((not sx_truthy((dom_is_active_element_p(old_node) if not sx_truthy(dom_is_active_element_p(old_node)) else dom_is_input_element_p(old_node))))): - return morph_children(old_node, new_node) - return NIL - return NIL - -# sync-attrs -def sync_attrs(old_el, new_el): - ra_str = (dom_get_attr(old_el, 'data-sx-reactive-attrs') if sx_truthy(dom_get_attr(old_el, 'data-sx-reactive-attrs')) else '') - reactive_attrs = ([] if sx_truthy(empty_p(ra_str)) else split(ra_str, ',')) - for attr in dom_attr_list(new_el): - name = first(attr) - val = nth(attr, 1) - if sx_truthy(((not sx_truthy((dom_get_attr(old_el, name) == val))) if not sx_truthy((not sx_truthy((dom_get_attr(old_el, name) == val)))) else (not sx_truthy(contains_p(reactive_attrs, name))))): - dom_set_attr(old_el, name, val) - return for_each(lambda attr: (lambda aname: (dom_remove_attr(old_el, aname) if sx_truthy(((not sx_truthy(dom_has_attr_p(new_el, aname))) if not sx_truthy((not sx_truthy(dom_has_attr_p(new_el, aname)))) else ((not sx_truthy(contains_p(reactive_attrs, aname))) if not sx_truthy((not sx_truthy(contains_p(reactive_attrs, aname)))) else (not sx_truthy((aname == 'data-sx-reactive-attrs')))))) else NIL))(first(attr)), dom_attr_list(old_el)) - -# morph-children -def morph_children(old_parent, new_parent): - _cells = {} - old_kids = dom_child_list(old_parent) - new_kids = dom_child_list(new_parent) - old_by_id = reduce(lambda acc, kid: (lambda id_: (_sx_begin(_sx_dict_set(acc, id_, kid), acc) if sx_truthy(id_) else acc))(dom_id(kid)), {}, old_kids) - _cells['oi'] = 0 - for new_child in new_kids: - match_id = dom_id(new_child) - match_by_id = (dict_get(old_by_id, match_id) if sx_truthy(match_id) else NIL) - if sx_truthy((match_by_id if not sx_truthy(match_by_id) else (not sx_truthy(is_nil(match_by_id))))): - if sx_truthy(((_cells['oi'] < len(old_kids)) if not sx_truthy((_cells['oi'] < len(old_kids))) else (not sx_truthy((match_by_id == nth(old_kids, _cells['oi'])))))): - dom_insert_before(old_parent, match_by_id, (nth(old_kids, _cells['oi']) if sx_truthy((_cells['oi'] < len(old_kids))) else NIL)) - morph_node(match_by_id, new_child) - _cells['oi'] = (_cells['oi'] + 1) - elif sx_truthy((_cells['oi'] < len(old_kids))): - old_child = nth(old_kids, _cells['oi']) - if sx_truthy((dom_id(old_child) if not sx_truthy(dom_id(old_child)) else (not sx_truthy(match_id)))): - dom_insert_before(old_parent, dom_clone(new_child), old_child) - else: - morph_node(old_child, new_child) - _cells['oi'] = (_cells['oi'] + 1) - else: - dom_append(old_parent, dom_clone(new_child)) - return for_each(lambda i: ((lambda leftover: (dom_remove_child(old_parent, leftover) if sx_truthy((dom_is_child_of_p(leftover, old_parent) if not sx_truthy(dom_is_child_of_p(leftover, old_parent)) else ((not sx_truthy(dom_has_attr_p(leftover, 'sx-preserve'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(leftover, 'sx-preserve')))) else (not sx_truthy(dom_has_attr_p(leftover, 'sx-ignore')))))) else NIL))(nth(old_kids, i)) if sx_truthy((i >= _cells['oi'])) else NIL), range(_cells['oi'], len(old_kids))) - -# morph-island-children -def morph_island_children(old_island, new_island): - 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]') - new_lake_map = {} - new_marsh_map = {} - for lake in new_lakes: - id_ = dom_get_attr(lake, 'data-sx-lake') - if sx_truthy(id_): - new_lake_map[id_] = lake - for marsh in new_marshes: - id_ = dom_get_attr(marsh, 'data-sx-marsh') - if sx_truthy(id_): - new_marsh_map[id_] = marsh - for old_lake in old_lakes: - id_ = dom_get_attr(old_lake, 'data-sx-lake') - new_lake = dict_get(new_lake_map, id_) - if sx_truthy(new_lake): - sync_attrs(old_lake, new_lake) - morph_children(old_lake, new_lake) - for old_marsh in old_marshes: - id_ = dom_get_attr(old_marsh, 'data-sx-marsh') - new_marsh = dict_get(new_marsh_map, id_) - if sx_truthy(new_marsh): - morph_marsh(old_marsh, new_marsh, old_island) - return process_signal_updates(new_island) - -# morph-marsh -def morph_marsh(old_marsh, new_marsh, island_el): - 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 sx_truthy((env if not sx_truthy(env) else (new_html if not sx_truthy(new_html) else (not sx_truthy(empty_p(new_html)))))): - parsed = parse(new_html) - sx_content = (invoke(transform, parsed) if sx_truthy(transform) else parsed) - dispose_marsh_scope(old_marsh) - return with_marsh_scope(old_marsh, lambda : (lambda new_dom: _sx_begin(dom_remove_children_after(old_marsh, NIL), dom_append(old_marsh, new_dom)))(render_to_dom(sx_content, env, NIL))) - else: - sync_attrs(old_marsh, new_marsh) - return morph_children(old_marsh, new_marsh) - -# process-signal-updates -def process_signal_updates(root): - signal_els = dom_query_all(root, '[data-sx-signal]') - return for_each(lambda el: (lambda spec: ((lambda colon_idx: ((lambda store_name: (lambda raw_value: _sx_begin((lambda parsed: reset_b(use_store(store_name), parsed))(json_parse(raw_value)), dom_remove_attr(el, 'data-sx-signal')))(slice(spec, (colon_idx + 1))))(slice(spec, 0, colon_idx)) if sx_truthy((colon_idx > 0)) else NIL))(index_of(spec, ':')) if sx_truthy(spec) else NIL))(dom_get_attr(el, 'data-sx-signal')), signal_els) - -# swap-dom-nodes -def swap_dom_nodes(target, new_nodes, strategy): - _match = strategy - if _match == 'innerHTML': - if sx_truthy(dom_is_fragment_p(new_nodes)): - return morph_children(target, new_nodes) - else: - wrapper = dom_create_element('div', NIL) - dom_append(wrapper, new_nodes) - return morph_children(target, wrapper) - elif _match == 'outerHTML': - parent = dom_parent(target) - ((lambda fc: (_sx_begin(morph_node(target, fc), (lambda sib: insert_remaining_siblings(parent, target, sib))(dom_next_sibling(fc))) if sx_truthy(fc) else dom_remove_child(parent, target)))(dom_first_child(new_nodes)) if sx_truthy(dom_is_fragment_p(new_nodes)) else morph_node(target, new_nodes)) - return parent - elif _match == 'afterend': - return dom_insert_after(target, new_nodes) - elif _match == 'beforeend': - return dom_append(target, new_nodes) - elif _match == 'afterbegin': - return dom_prepend(target, new_nodes) - elif _match == 'beforebegin': - return dom_insert_before(dom_parent(target), new_nodes, target) - elif _match == 'delete': - return dom_remove_child(dom_parent(target), target) - elif _match == 'none': - return NIL - else: - if sx_truthy(dom_is_fragment_p(new_nodes)): - return morph_children(target, new_nodes) - else: - wrapper = dom_create_element('div', NIL) - dom_append(wrapper, new_nodes) - return morph_children(target, wrapper) - -# insert-remaining-siblings -def insert_remaining_siblings(parent, ref_node, sib): - if sx_truthy(sib): - next = dom_next_sibling(sib) - dom_insert_after(ref_node, sib) - return insert_remaining_siblings(parent, sib, next) - return NIL - -# swap-html-string -def swap_html_string(target, html, strategy): - _match = strategy - if _match == 'innerHTML': - return dom_set_inner_html(target, html) - elif _match == 'outerHTML': - parent = dom_parent(target) - dom_insert_adjacent_html(target, 'afterend', html) - dom_remove_child(parent, target) - return parent - elif _match == 'afterend': - return dom_insert_adjacent_html(target, 'afterend', html) - elif _match == 'beforeend': - return dom_insert_adjacent_html(target, 'beforeend', html) - elif _match == 'afterbegin': - return dom_insert_adjacent_html(target, 'afterbegin', html) - elif _match == 'beforebegin': - return dom_insert_adjacent_html(target, 'beforebegin', html) - elif _match == 'delete': - return dom_remove_child(dom_parent(target), target) - elif _match == 'none': - return NIL - else: - return dom_set_inner_html(target, html) - -# handle-history -def handle_history(el, url, resp_headers): - 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') - if sx_truthy(hdr_replace): - return browser_replace_state(hdr_replace) - elif sx_truthy((push_url if not sx_truthy(push_url) else (not sx_truthy((push_url == 'false'))))): - return browser_push_state((url if sx_truthy((push_url == 'true')) else push_url)) - elif sx_truthy((replace_url if not sx_truthy(replace_url) else (not sx_truthy((replace_url == 'false'))))): - return browser_replace_state((url if sx_truthy((replace_url == 'true')) else replace_url)) - return NIL - -# PRELOAD_TTL -PRELOAD_TTL = 30000 - -# preload-cache-get -def preload_cache_get(cache, url): - entry = dict_get(cache, url) - if sx_truthy(is_nil(entry)): - return NIL - else: - if sx_truthy(((now_ms() - get(entry, 'timestamp')) > PRELOAD_TTL)): - dict_delete(cache, url) - return NIL - else: - dict_delete(cache, url) - return entry - -# preload-cache-set -def preload_cache_set(cache, url, text, content_type): - return _sx_dict_set(cache, url, {'text': text, 'content-type': content_type, 'timestamp': now_ms()}) - -# classify-trigger -def classify_trigger(trigger): - event = get(trigger, 'event') - if sx_truthy((event == 'every')): - return 'poll' - elif sx_truthy((event == 'intersect')): - return 'intersect' - elif sx_truthy((event == 'load')): - return 'load' - elif sx_truthy((event == 'revealed')): - return 'revealed' - else: - return 'event' - -# should-boost-link? -def should_boost_link_p(link): - href = dom_get_attr(link, 'href') - return (href if not sx_truthy(href) else ((not sx_truthy(starts_with_p(href, '#'))) if not sx_truthy((not sx_truthy(starts_with_p(href, '#')))) else ((not sx_truthy(starts_with_p(href, 'javascript:'))) if not sx_truthy((not sx_truthy(starts_with_p(href, 'javascript:')))) else ((not sx_truthy(starts_with_p(href, 'mailto:'))) if not sx_truthy((not sx_truthy(starts_with_p(href, 'mailto:')))) else (browser_same_origin_p(href) if not sx_truthy(browser_same_origin_p(href)) else ((not sx_truthy(dom_has_attr_p(link, 'sx-get'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(link, 'sx-get')))) else ((not sx_truthy(dom_has_attr_p(link, 'sx-post'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(link, 'sx-post')))) else (not sx_truthy(dom_has_attr_p(link, 'sx-disable')))))))))) - -# should-boost-form? -def should_boost_form_p(form): - return ((not sx_truthy(dom_has_attr_p(form, 'sx-get'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(form, 'sx-get')))) else ((not sx_truthy(dom_has_attr_p(form, 'sx-post'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(form, 'sx-post')))) else (not sx_truthy(dom_has_attr_p(form, 'sx-disable'))))) - -# parse-sse-swap -def parse_sse_swap(el): - return (dom_get_attr(el, 'sx-sse-swap') if sx_truthy(dom_get_attr(el, 'sx-sse-swap')) else 'message') - - -# === Transpiled from router (client-side route matching) === - -# split-path-segments -def split_path_segments(path): - trimmed = (slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path) - trimmed2 = (slice(trimmed, 0, (len(trimmed) - 1)) if sx_truthy(((not sx_truthy(empty_p(trimmed))) if not sx_truthy((not sx_truthy(empty_p(trimmed)))) else ends_with_p(trimmed, '/'))) else trimmed) - if sx_truthy(empty_p(trimmed2)): - return [] - else: - return split(trimmed2, '/') - -# make-route-segment -def make_route_segment(seg): - if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))): - param_name = slice(seg, 1, (len(seg) - 1)) - d = {} - d['type'] = 'param' - d['value'] = param_name - return d - else: - d = {} - d['type'] = 'literal' - d['value'] = seg - return d - -# parse-route-pattern -def parse_route_pattern(pattern): - segments = split_path_segments(pattern) - return map(make_route_segment, segments) - -# match-route-segments -def match_route_segments(path_segs, parsed_segs): - _cells = {} - if sx_truthy((not sx_truthy((len(path_segs) == len(parsed_segs))))): - return NIL - else: - params = {} - _cells['matched'] = True - for_each_indexed(lambda i, parsed_seg: ((lambda path_seg: (lambda seg_type: ((_sx_cell_set(_cells, 'matched', False) if sx_truthy((not sx_truthy((path_seg == get(parsed_seg, 'value'))))) else NIL) if sx_truthy((seg_type == 'literal')) else (_sx_dict_set(params, get(parsed_seg, 'value'), path_seg) if sx_truthy((seg_type == 'param')) else _sx_cell_set(_cells, 'matched', False))))(get(parsed_seg, 'type')))(nth(path_segs, i)) if sx_truthy(_cells['matched']) else NIL), parsed_segs) - if sx_truthy(_cells['matched']): - return params - else: - return NIL - -# match-route -def match_route(path, pattern): - path_segs = split_path_segments(path) - parsed_segs = parse_route_pattern(pattern) - return match_route_segments(path_segs, parsed_segs) - -# find-matching-route -def find_matching_route(path, routes): - _cells = {} - path_segs = split_path_segments(path) - _cells['result'] = NIL - for route in routes: - if sx_truthy(is_nil(_cells['result'])): - params = match_route_segments(path_segs, get(route, 'parsed')) - if sx_truthy((not sx_truthy(is_nil(params)))): - matched = merge(route, {}) - matched['params'] = params - _cells['result'] = matched - return _cells['result'] - - # === Transpiled from signals (reactive signal runtime) === # signal diff --git a/shared/sx/ref/test-types.sx b/shared/sx/ref/test-types.sx new file mode 100644 index 0000000..b16e5ac --- /dev/null +++ b/shared/sx/ref/test-types.sx @@ -0,0 +1,285 @@ +;; ========================================================================== +;; 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")))))) diff --git a/shared/sx/ref/types.sx b/shared/sx/ref/types.sx new file mode 100644 index 0000000..87f2d35 --- /dev/null +++ b/shared/sx/ref/types.sx @@ -0,0 +1,601 @@ +;; ========================================================================== +;; 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) + ;; 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 prim-types) + (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 (dict-has? type-env name) + (dict-get type-env name) + ;; Builtins + (if (= name "true") "boolean" + (if (= name "false") "boolean" + (if (= name "nil") "nil" + ;; Check primitive return types + (if (dict-has? prim-types name) + (dict-get prim-types name) + "any")))))) + (if (= kind "dict") "dict" + (if (= kind "list") + (infer-list-type node type-env prim-types) + "any"))))))))))) + + +(define infer-list-type + (fn (node type-env prim-types) + ;; 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) + (if (= name "when") + (if (>= (len args) 2) + (type-union (infer-type (last args) type-env prim-types) "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) + (if (or (= name "do") (= name "begin")) + (if (empty? args) "nil" + (infer-type (last args) type-env prim-types)) + (if (or (= name "lambda") (= name "fn")) + "lambda" + (if (= name "and") + (if (empty? args) "boolean" + (infer-type (last args) type-env prim-types)) + (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)) args))) + (if (= name "map") + ;; map returns a list + (if (>= (len args) 2) + (let ((fn-type (infer-type (first args) type-env prim-types))) + ;; 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) + "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 (starts-with? name "~") + "element" ;; component call + ;; Regular function call: look up return type + (if (dict-has? prim-types name) + (dict-get prim-types name) + "any")))))))))))))))))))))))) + + +(define infer-if-type + (fn (args type-env prim-types) + ;; (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))) + (if (>= (len args) 3) + (type-union then-type (infer-type (nth args 2) type-env prim-types)) + (type-union then-type "nil")))))) + + +(define infer-let-type + (fn (args type-env prim-types) + ;; (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))) + (dict-set! extended name val-type)))) + bindings) + (infer-type body extended prim-types))))) + + +;; -------------------------------------------------------------------------- +;; 7. Diagnostic types +;; -------------------------------------------------------------------------- +;; Diagnostics are dicts: +;; {:level "error"|"warning"|"info" +;; :message "human-readable explanation" +;; :component "~name" (or nil for top-level) +;; :expr } + +(define make-diagnostic + (fn (level message component expr) + {:level level + :message message + :component component + :expr expr})) + + +;; -------------------------------------------------------------------------- +;; 8. Call-site checking +;; -------------------------------------------------------------------------- + +(define check-primitive-call + (fn (name args type-env prim-types) + ;; Check a primitive call site. Returns list of diagnostics. + (let ((diagnostics (list))) + ;; Currently just checks return types are used correctly. + ;; Phase 5 adds param type checking when primitives.sx has + ;; typed params. + diagnostics))) + + +(define check-component-call + (fn (comp-name comp call-args type-env prim-types) + ;; 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 (dict-has? param-types key-name) + (let ((expected (dict-get param-types key-name)) + (actual (infer-type val-expr type-env prim-types))) + (when (and (not (type-any? expected)) + (not (type-any? actual)) + (not (subtype? actual expected))) + (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 (dict-has? param-types param-name) + (not (contains? provided-keys param-name)) + (not (type-nullable? (dict-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 type-env prim-types env diagnostics) + ;; Recursively walk an AST and collect diagnostics. + (let ((kind (type-of node))) + (when (= kind "list") + (when (not (empty? node)) + (let ((head (first node)) + (args (rest node))) + ;; Check component calls + (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))))) + + ;; 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))) + (dict-set! extended bname val-type)))) + bindings) + (for-each + (fn (body) + (check-body-walk body comp-name extended prim-types env diagnostics)) + 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))) + (check-body-walk def-val comp-name type-env prim-types env diagnostics)))))) + + ;; Recurse into all child expressions + (for-each + (fn (child) + (check-body-walk child comp-name type-env prim-types env diagnostics)) + args))))))) + + +;; -------------------------------------------------------------------------- +;; 10. Check a single component +;; -------------------------------------------------------------------------- + +(define check-component + (fn (comp-name env prim-types) + ;; Type-check a component's body. Returns list of diagnostics. + (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)) + (dict-has? param-types p)) + (dict-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 env diagnostics))) + diagnostics))) + + +;; -------------------------------------------------------------------------- +;; 11. Check all components in an environment +;; -------------------------------------------------------------------------- + +(define check-all + (fn (env prim-types) + ;; Type-check every component in the environment. + ;; 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))))) + (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 io-declarations) + ;; 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 (dict-get decl "name")) + (returns (dict-get decl "returns"))) + (when (and (not (nil? name)) (not (nil? returns))) + (dict-set! registry name returns)))) + prim-declarations) + (for-each + (fn (decl) + (let ((name (dict-get decl "name")) + (returns (dict-get decl "returns"))) + (when (and (not (nil? name)) (not (nil? returns))) + (dict-set! registry name returns)))) + io-declarations) + registry))) + + +;; -------------------------------------------------------------------------- +;; 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 +;; +;; -------------------------------------------------------------------------- diff --git a/shared/sx/tests/run.py b/shared/sx/tests/run.py index c679d59..25c7f19 100644 --- a/shared/sx/tests/run.py +++ b/shared/sx/tests/run.py @@ -262,6 +262,7 @@ SPECS = { "engine": {"file": "test-engine.sx", "needs": []}, "orchestration": {"file": "test-orchestration.sx", "needs": []}, "signals": {"file": "test-signals.sx", "needs": ["make-signal"]}, + "types": {"file": "test-types.sx", "needs": []}, } REF_DIR = os.path.join(_HERE, "..", "ref") @@ -722,6 +723,78 @@ def _load_signals(env): env["batch"] = _batch +def _load_types(env): + """Load types.sx spec — gradual type system.""" + from shared.sx.types import Component + + def _component_param_types(c): + return getattr(c, 'param_types', None) + + def _component_set_param_types(c, d): + c.param_types = d + + env["component-param-types"] = _component_param_types + env["component-set-param-types!"] = _component_set_param_types + + # test-prim-types: a minimal type registry for testing + def _test_prim_types(): + return { + "+": "number", "-": "number", "*": "number", "/": "number", + "mod": "number", "abs": "number", "floor": "number", + "ceil": "number", "round": "number", "min": "number", + "max": "number", "parse-int": "number", "parse-float": "number", + "=": "boolean", "!=": "boolean", "<": "boolean", ">": "boolean", + "<=": "boolean", ">=": "boolean", + "str": "string", "string-length": "number", + "substring": "string", "upcase": "string", "downcase": "string", + "trim": "string", "split": "list", "join": "string", + "string-contains?": "boolean", "starts-with?": "boolean", + "ends-with?": "boolean", "replace": "string", + "not": "boolean", "nil?": "boolean", "number?": "boolean", + "string?": "boolean", "list?": "boolean", "dict?": "boolean", + "boolean?": "boolean", "symbol?": "boolean", "empty?": "boolean", + "list": "list", "first": "any", "rest": "list", "nth": "any", + "last": "any", "cons": "list", "append": "list", + "reverse": "list", "len": "number", "contains?": "boolean", + "flatten": "list", "concat": "list", "slice": "list", + "range": "list", "sort": "list", "sort-by": "list", + "map": "list", "filter": "list", "reduce": "any", + "some": "boolean", "every?": "boolean", + "dict": "dict", "assoc": "dict", "dissoc": "dict", + "get": "any", "keys": "list", "vals": "list", + "has-key?": "boolean", "merge": "dict", + } + + env["test-prim-types"] = _test_prim_types + env["test-env"] = lambda: env + + # Try bootstrapped types first, fall back to eval + try: + from shared.sx.ref.sx_ref import ( + subtype_p, type_union, narrow_type, + infer_type, check_component_call, check_component, + check_all, build_type_registry, type_any_p, + type_never_p, type_nullable_p, nullable_base, + narrow_exclude_nil, narrow_exclude, + ) + env["subtype?"] = subtype_p + env["type-union"] = type_union + env["narrow-type"] = narrow_type + env["infer-type"] = infer_type + env["check-component-call"] = check_component_call + env["check-component"] = check_component + env["check-all"] = check_all + env["build-type-registry"] = build_type_registry + env["type-any?"] = type_any_p + env["type-never?"] = type_never_p + env["type-nullable?"] = type_nullable_p + env["nullable-base"] = nullable_base + env["narrow-exclude-nil"] = narrow_exclude_nil + env["narrow-exclude"] = narrow_exclude + except ImportError: + eval_file("types.sx", env) + + def main(): global passed, failed, test_num @@ -769,6 +842,8 @@ def main(): _load_orchestration(env) if spec_name == "signals": _load_signals(env) + if spec_name == "types": + _load_types(env) print(f"# --- {spec_name} ---") eval_file(spec["file"], env) diff --git a/shared/sx/types.py b/shared/sx/types.py index 97f0e2f..74ca498 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -170,6 +170,7 @@ class Component: deps: set[str] = field(default_factory=set) # transitive component deps (~names) io_refs: set[str] | None = None # transitive IO primitive refs (None = not computed) affinity: str = "auto" # "auto" | "client" | "server" + param_types: dict[str, Any] | None = None # {param_name: type_expr} for gradual typing @property def is_pure(self) -> bool: