From 1bdd1411788b6a73b5199f424cbd2214e0f704a1 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 23 Apr 2026 11:10:11 +0000 Subject: [PATCH] =?UTF-8?q?HS:=20chain=20`.x`=20after=20`f()`;=20translate?= =?UTF-8?q?=20window.X=20arrow=20setups=20=E2=80=94=20+5=20functionCalls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser (lib/hyperscript/parser.sx): - parse-poss case for "(" (function call) was building (call ...) and returning without recursing, so `f().x` lost the `.x` suffix and the compiler emitted (let ((it (f))) (hs-query-first ".x")). Now it tail- calls parse-poss on the constructed call so chains like f().x.y(), obj.method().prop, etc. parse correctly. Generator (tests/playwright/generate-sx-tests.py): - New js_expr_to_sx: translates arrow functions ((args) => body), object literals, simple property access / method calls / arith. Falls back through js_val_to_sx for primitives. - New extract_window_setups: scans `evaluate(() => { window.X = Y })` blocks (with balanced-brace inner-body extraction) and returns (name, sx_value) pairs. - Pattern 1 / Pattern 2 in generate_eval_only_test merge those window setups into the locals passed to eval-hs-locals, so HS expressions can reference globals defined by the test prelude. - Object literal value parsing now goes through js_expr_to_sx first, so `{x: x, y: y}` yields `{:x x :y y}` (was `{:x "x" :y "y"}`). Net: hs-upstream-expressions/functionCalls 0/12 → 5/12 (+5). Smoke-checked put/set/scoping/possessiveExpression — no regressions. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/hyperscript/parser.sx | 2 +- shared/static/wasm/sx/hs-parser.sx | 2 +- spec/tests/test-hyperscript-behavioral.sx | 20 +- tests/playwright/generate-sx-tests.py | 235 ++++++++++++++++++++-- 4 files changed, 234 insertions(+), 25 deletions(-) diff --git a/lib/hyperscript/parser.sx b/lib/hyperscript/parser.sx index 3739084b..3a657199 100644 --- a/lib/hyperscript/parser.sx +++ b/lib/hyperscript/parser.sx @@ -331,7 +331,7 @@ ((= (tp-type) "paren-open") (let ((args (parse-call-args))) - (cons (quote call) (cons obj args)))) + (parse-poss (cons (quote call) (cons obj args))))) ((= (tp-type) "bracket-open") (do (adv!) diff --git a/shared/static/wasm/sx/hs-parser.sx b/shared/static/wasm/sx/hs-parser.sx index 3739084b..3a657199 100644 --- a/shared/static/wasm/sx/hs-parser.sx +++ b/shared/static/wasm/sx/hs-parser.sx @@ -331,7 +331,7 @@ ((= (tp-type) "paren-open") (let ((args (parse-call-args))) - (cons (quote call) (cons obj args)))) + (parse-poss (cons (quote call) (cons obj args))))) ((= (tp-type) "bracket-open") (do (adv!) diff --git a/spec/tests/test-hyperscript-behavioral.sx b/spec/tests/test-hyperscript-behavioral.sx index 68f1dc5e..17916bb0 100644 --- a/spec/tests/test-hyperscript-behavioral.sx +++ b/spec/tests/test-hyperscript-behavioral.sx @@ -5200,32 +5200,32 @@ ;; ── expressions/functionCalls (12 tests) ── (defsuite "hs-upstream-expressions/functionCalls" (deftest "can access a property of a call's result" - (assert= (eval-hs "makePoint(3, 4).x") 3) - (assert= (eval-hs "makePoint(3, 4).y") 4) + (assert= (eval-hs-locals "makePoint(3, 4).x" (list (list (quote makePoint) (fn (x y) {:x x :y y})))) 3) + (assert= (eval-hs-locals "makePoint(3, 4).y" (list (list (quote makePoint) (fn (x y) {:x x :y y})))) 4) ) (deftest "can chain calls on the result of a call" - (assert= (eval-hs "getObj().greet()") "hi") + (assert= (eval-hs-locals "getObj().greet()" (list (list (quote getObj) (fn () {:greet (fn () "hi")})))) "hi") ) (deftest "can invoke function on object" - (assert= (eval-hs "obj.getValue()") "foo") + (assert= (eval-hs-locals "obj.getValue()" (list (list (quote obj) {:value "foo" :getValue "function () { return this.value }"}))) "foo") ) (deftest "can invoke function on object w/ async arg" (error "SKIP (untranslated): can invoke function on object w/ async arg")) (deftest "can invoke function on object w/ async root & arg" (error "SKIP (untranslated): can invoke function on object w/ async root & arg")) (deftest "can invoke global function" - (assert= (eval-hs "identity(\"foo\")") "foo") + (assert= (eval-hs-locals "identity(\"foo\")" (list (list (quote identity) (fn (x) x)))) "foo") ) (deftest "can invoke global function w/ async arg" (error "SKIP (untranslated): can invoke global function w/ async arg")) (deftest "can pass an array literal as an argument" - (assert= (eval-hs "sum([1, 2, 3, 4])") 10) + (assert= (eval-hs-locals "sum([1, 2, 3, 4])" (list (list (quote sum) (fn (arr) (host-call arr "reduce" (fn (a b) (+ a b)) 0))))) 10) ) (deftest "can pass an expression as an argument" - (assert= (eval-hs "double(3 + 4)") 14) + (assert= (eval-hs-locals "double(3 + 4)" (list (list (quote double) (fn (n) (* n 2))))) 14) ) (deftest "can pass an object literal as an argument" - (assert= (eval-hs "getName({name: 'Alice'})") "Alice") + (assert= (eval-hs-locals "getName({name: 'Alice'})" (list (list (quote getName) (fn (o) (host-get o "name"))))) "Alice") ) (deftest "can pass multiple arguments" (hs-cleanup!) @@ -5237,7 +5237,7 @@ (assert= (dom-text-content _el-div) "6") )) (deftest "can pass no arguments" - (assert= (eval-hs "getFortyTwo()") 42) + (assert= (eval-hs-locals "getFortyTwo()" (list (list (quote getFortyTwo) (fn () 42)))) 42) ) ) @@ -5842,7 +5842,7 @@ (assert= (eval-hs-locals "doh of foo.bar" (list (list (quote foo) {:bar {:doh "foo"}}))) "foo") ) (deftest "property access on function result" - (assert= (eval-hs "makeObj().name") "hi") + (assert= (eval-hs-locals "makeObj().name" (list (list (quote makeObj) (fn () {:name "hi"})))) "hi") ) (deftest "works properly w/ boolean properties" (hs-cleanup!) diff --git a/tests/playwright/generate-sx-tests.py b/tests/playwright/generate-sx-tests.py index 0fe9c6f2..a4a51f43 100644 --- a/tests/playwright/generate-sx-tests.py +++ b/tests/playwright/generate-sx-tests.py @@ -984,7 +984,9 @@ def js_val_to_sx(val): if not m: return f'"{val}"' key = m.group(1) or m.group(2) or m.group(3) - v = js_val_to_sx(m.group(4)) + # Try expression translation first (handles identifiers, arrows, + # arith); fall back to literal for things we don't know. + v = js_expr_to_sx(m.group(4)) or js_val_to_sx(m.group(4)) parts.append(f':{key} {v}') return '{' + ' '.join(parts) + '}' try: @@ -1024,6 +1026,198 @@ def split_top_level(s): return parts +_JS_ARROW = re.compile( + r'\(\s*([^)]*?)\s*\)\s*=>\s*(.+)$', re.DOTALL +) + + +def js_expr_to_sx(expr): + """Translate a small JS expression to SX. Handles: + - arrow `(args) => body` → (fn (args) ) + - object literal `{a: 1}` → {:a 1} + - array literal `[1, 2]` → (list 1 2) + - binary ops `a + b * c` → (+ a (* b c)) — naive flat for now + - bare identifier or literal → as-is + Returns the SX string, or None if we don't know how. + """ + expr = expr.strip() + if not expr: + return None + # Strip trailing semicolons/whitespace. + expr = expr.rstrip(';').strip() + + # Arrow functions `(args) => body` or `arg => body`. + am = _JS_ARROW.match(expr) + if am: + args_str = am.group(1).strip() + body_str = am.group(2).strip() + params = [a.strip() for a in args_str.split(',') if a.strip()] if args_str else [] + # Arrow body may itself be `({...})` (parenthesised object literal). + if body_str.startswith('(') and body_str.endswith(')'): + inner = body_str[1:-1].strip() + if inner.startswith('{'): + body_str = inner + body_sx = js_expr_to_sx(body_str) + if body_sx is None: + return None + return f'(fn ({" ".join(params)}) {body_sx})' + + # Balanced outer parens unwrap (after arrow check, so `(x)` alone works). + if expr.startswith('(') and expr.endswith(')'): + depth = 0 + balanced = True + for i, ch in enumerate(expr): + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + if depth == 0 and i != len(expr) - 1: + balanced = False + break + if balanced: + return js_expr_to_sx(expr[1:-1]) + + # Object literal {a: 1, b: {...}} — reuse js_val_to_sx + if expr.startswith('{') and expr.endswith('}'): + return js_val_to_sx(expr) + + # Array literal [a, b] + if expr.startswith('[') and expr.endswith(']'): + return js_val_to_sx(expr) + + # Quoted string + if (expr.startswith('"') and expr.endswith('"')) or (expr.startswith("'") and expr.endswith("'")): + return '"' + expr[1:-1].replace('"', '\\"') + '"' + + # Numeric literal + try: + float(expr) + return expr + except ValueError: + pass + + # Naive binary-op rewriting: split on top-level + - * / and wrap. + for op in ('+', '-', '*', '/'): + parts = [] + depth = 0 + in_str = None + cur = [] + for ch in expr: + if in_str: + cur.append(ch) + if ch == in_str: + in_str = None + continue + if ch in ('"', "'"): + in_str = ch + cur.append(ch) + continue + if ch in '([{': + depth += 1 + elif ch in ')]}': + depth -= 1 + if ch == op and depth == 0: + parts.append(''.join(cur)) + cur = [] + else: + cur.append(ch) + if cur: + parts.append(''.join(cur)) + if len(parts) > 1 and all(p.strip() for p in parts): + sub = [js_expr_to_sx(p) for p in parts] + if all(s is not None for s in sub): + return '(' + op + ' ' + ' '.join(sub) + ')' + + # Method call: o.method(args) + m = re.match(r'^(\w+)\.(\w+)\((.*)\)$', expr, re.DOTALL) + if m: + obj, method, args = m.group(1), m.group(2), m.group(3) + arg_sx = [] + for a in (split_top_level(args) if args.strip() else []): + s = js_expr_to_sx(a.strip()) + if s is None: + return None + arg_sx.append(s) + return f'(host-call {obj} "{method}" {" ".join(arg_sx)})'.strip() + + # Property access: o.prop + m = re.match(r'^(\w+)\.(\w+)$', expr) + if m: + return f'(host-get {m.group(1)} "{m.group(2)}")' + + # Bare identifier + if re.match(r'^[A-Za-z_]\w*$', expr): + return expr + + return None + + +def extract_window_setups(body): + """Find `evaluate(() => { window.NAME = VALUE; ... })` blocks and return + a list of (name, sx_value) pairs. Skips assignments we can't translate. + """ + setups = [] + # Each evaluate body may contain multiple `window.X = Y` (semicolon-separated). + # Match the inner braces of evaluate(() => { ... }), with balanced braces. + for em in re.finditer(r'evaluate\(\s*\(\)\s*=>\s*\{', body): + start = em.end() + # Find matching close brace. + depth, i, in_str = 1, start, None + while i < len(body) and depth > 0: + ch = body[i] + if in_str: + if ch == in_str and body[i - 1] != '\\': + in_str = None + elif ch in ('"', "'", '`'): + in_str = ch + elif ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + i += 1 + if depth != 0: + continue + inner = body[start:i - 1] + # Split on top-level semicolons. + for stmt in split_top_level_chars(inner, ';'): + sm = re.match(r'\s*window\.(\w+)\s*=\s*(.+?)\s*$', stmt, re.DOTALL) + if not sm: + continue + name = sm.group(1) + sx_val = js_expr_to_sx(sm.group(2).strip()) + if sx_val is not None: + setups.append((name, sx_val)) + return setups + + +def split_top_level_chars(s, sep_char): + """Split a string on `sep_char` at top level (depth-0, outside strings).""" + parts = [] + depth, in_str, cur = 0, None, [] + for ch in s: + if in_str: + cur.append(ch) + if ch == in_str: + in_str = None + continue + if ch in ('"', "'", '`'): + in_str = ch + cur.append(ch) + continue + if ch in '([{': + depth += 1 + elif ch in ')]}': + depth -= 1 + if ch == sep_char and depth == 0: + parts.append(''.join(cur)) + cur = [] + else: + cur.append(ch) + if cur: + parts.append(''.join(cur)) + return parts + + def extract_hs_expr(raw): """Clean a HS expression extracted from run() call.""" # Remove surrounding whitespace and newlines @@ -1055,6 +1249,22 @@ def generate_eval_only_test(test, idx): assertions = [] + # Window setups from `evaluate(() => { window.X = Y })` blocks. + # These get merged into local_pairs so the HS expression can reference them. + window_setups = extract_window_setups(body) + + def emit_eval(hs_expr, expected_sx, extra_locals=None): + """Emit an assertion using eval-hs / eval-hs-locals / eval-hs-with-me + as appropriate, given the window setups and any per-call locals. + """ + pairs = list(window_setups) + list(extra_locals or []) + if pairs: + locals_sx = '(list ' + ' '.join( + f'(list (quote {n}) {v})' for n, v in pairs + ) + ')' + return f' (assert= (eval-hs-locals "{hs_expr}" {locals_sx}) {expected_sx})' + return f' (assert= (eval-hs "{hs_expr}") {expected_sx})' + # Shared sub-pattern for run() call with optional String.raw and extra args: # run(QUOTE expr QUOTE) or run(QUOTE expr QUOTE, opts) or run(String.raw`expr`, opts) # Extra args can contain nested parens/braces, so we allow anything non-greedy up to the @@ -1074,19 +1284,14 @@ def generate_eval_only_test(test, idx): # Check for { me: X } or { locals: { x: X, y: Y } } in opts me_match = re.search(r'\bme:\s*(\d+)', opts_str) locals_match = re.search(r'locals:\s*\{([^}]+)\}', opts_str) + extra = [] if locals_match: - local_pairs = [] for lm in re.finditer(r'(\w+)\s*:\s*([^,}]+)', locals_match.group(1)): - lname = lm.group(1) - lval = js_val_to_sx(lm.group(2).strip()) - local_pairs.append((lname, lval)) - locals_sx = '(list ' + ' '.join(f'(list (quote {n}) {v})' for n, v in local_pairs) + ')' if local_pairs else '(list)' - assertions.append(f' (assert= (eval-hs-locals "{hs_expr}" {locals_sx}) {expected_sx})') - elif me_match: - me_val = me_match.group(1) - assertions.append(f' (assert= (eval-hs-with-me "{hs_expr}" {me_val}) {expected_sx})') + extra.append((lm.group(1), js_val_to_sx(lm.group(2).strip()))) + if me_match and not (window_setups or extra): + assertions.append(f' (assert= (eval-hs-with-me "{hs_expr}" {me_match.group(1)}) {expected_sx})') else: - assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})') + assertions.append(emit_eval(hs_expr, expected_sx, extra)) # Pattern 1b: Inline — run("expr", opts).toEqual([...]) for m in re.finditer( @@ -1095,7 +1300,7 @@ def generate_eval_only_test(test, idx): ): hs_expr = extract_hs_expr(m.group(2)) expected_sx = js_val_to_sx(m.group(3)) - assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})') + assertions.append(emit_eval(hs_expr, expected_sx)) # Pattern 1c: Inline — run("expr", opts).toEqual({...}) if not assertions: @@ -1154,7 +1359,11 @@ def generate_eval_only_test(test, idx): m = re.match(r'^(\w+)\s*:\s*(.+)$', kv, re.DOTALL) if m: local_pairs.append((m.group(1), js_val_to_sx(m.group(2).strip()))) - locals_sx = '(list ' + ' '.join(f'(list (quote {n}) {v})' for n, v in local_pairs) + ')' if local_pairs else None + # Merge window setups into local_pairs so evaluate() globals are visible to HS. + merged_pairs = list(window_setups) + local_pairs + locals_sx = '(list ' + ' '.join( + f'(list (quote {n}) {v})' for n, v in merged_pairs + ) + ')' if merged_pairs else None def eval_call(expr): return f'(eval-hs-locals "{expr}" {locals_sx})' if locals_sx else f'(eval-hs "{expr}")' for m in re.finditer(r'expect\((' + re.escape(var_name) + r'(?:\["[^"]+"\]|\.\w+)?)\)\.toBe\(([^)]+)\)', body):