HS: chain .x after f(); translate window.X arrow setups — +5 functionCalls

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 11:10:11 +00:00
parent f8d30f50fb
commit 1bdd141178
4 changed files with 234 additions and 25 deletions

View File

@@ -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!)

View File

@@ -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!)

View File

@@ -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!)

View File

@@ -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) <body>)
- 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):