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:
@@ -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!)
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user