js-on-sx: .toString()/.toFixed()/.valueOf() on numbers and booleans

js-invoke-method now branches on (number? recv) and (boolean? recv) before
falling through to the generic dict/fn path. js-invoke-number-method handles
toString (incl. radix 2-36), toFixed, valueOf, toLocaleString, toPrecision,
toExponential. js-invoke-boolean-method handles toString and valueOf.

Numbers had no .toString() on bare values before — (5).toString() crashed
with 'TypeError: toString is not a function'. This is one of the bigger
scoreboard misses on built-ins/Number category.

10 new unit tests, 469/471 total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 06:51:58 +00:00
parent 00bb21ca13
commit baa5cd9341
2 changed files with 202 additions and 54 deletions

View File

@@ -105,6 +105,125 @@
((= key "length") 0)
(else :js-undefined))))
(define
js-invoke-number-method
(fn
(recv key args)
(cond
((= key "toString")
(let
((radix (if (empty? args) 10 (nth args 0))))
(js-num-to-str-radix
recv
(if
(or (= radix nil) (js-undefined? radix))
10
(js-to-number radix)))))
((= key "toFixed")
(js-number-to-fixed
recv
(if (empty? args) 0 (js-to-number (nth args 0)))))
((= key "valueOf") recv)
((= key "toLocaleString") (js-to-string recv))
((= key "toPrecision") (js-to-string recv))
((= key "toExponential") (js-to-string recv))
(else
(error
(str
"TypeError: "
(js-to-string key)
" is not a function (on number)"))))))
(define
js-invoke-boolean-method
(fn
(recv key args)
(cond
((= key "toString") (if recv "true" "false"))
((= key "valueOf") recv)
(else
(error
(str
"TypeError: "
(js-to-string key)
" is not a function (on boolean)"))))))
(define
js-num-to-str-radix
(fn
(n radix)
(cond
((and (number? n) (not (= n n))) "NaN")
((= n (/ 1 0)) "Infinity")
((= n (/ -1 0)) "-Infinity")
((or (= radix 10) (= radix nil) (js-undefined? radix))
(js-to-string n))
(else
(let
((int-n (js-math-trunc n)))
(if
(< int-n 0)
(str "-" (js-num-to-str-radix-rec (- 0 int-n) radix ""))
(js-num-to-str-radix-rec int-n radix "")))))))
(define
js-num-to-str-radix-rec
(fn
(n radix acc)
(if
(= n 0)
(if (= acc "") "0" acc)
(let
((d (mod n radix)) (rest (js-math-trunc (/ n radix))))
(js-num-to-str-radix-rec rest radix (str (js-digit-char d) acc))))))
(define
js-digit-char
(fn
(d)
(cond
((< d 10) (js-to-string d))
(else (let ((offset (+ 97 (- d 10)))) (js-code-to-char offset))))))
(define
js-number-to-fixed
(fn
(n digits)
(let
((d (js-math-trunc digits)))
(if
(< d 1)
(js-to-string (js-math-round n))
(let
((scale (js-pow-int 10 d)))
(let
((scaled (js-math-round (* n scale))))
(let
((int-part (js-math-trunc (/ scaled scale)))
(frac-part
(- scaled (* (js-math-trunc (/ scaled scale)) scale))))
(let
((frac-abs (if (< frac-part 0) (- 0 frac-part) frac-part)))
(str
(js-to-string int-part)
"."
(js-pad-int-str (js-to-string (js-math-trunc frac-abs)) d))))))))))
;; parse a decimal number from a trimmed non-empty string.
;; s — source
;; i — cursor
;; acc — integer part so far (or total for decimals)
;; sign — 1 or -1
;; frac? — are we past the decimal point
;; fdiv — divisor used to scale fraction digits (only if frac?)
(define
js-pow-int
(fn (b e) (if (<= e 0) 1 (* b (js-pow-int b (- e 1))))))
(define
js-pad-int-str
(fn (s n) (if (>= (len s) n) s (js-pad-int-str (str "0" s) n))))
(define
js-apply-fn
(fn
@@ -136,6 +255,8 @@
(nth args 5)))
(else (apply callable args))))))
;; ── String coercion (ToString) ────────────────────────────────────
(define
js-invoke-method
(fn
@@ -144,6 +265,8 @@
((and (js-promise? recv) (js-promise-builtin-method? key))
(js-invoke-promise-method recv key args))
((js-regex? recv) (js-regex-invoke-method recv key args))
((number? recv) (js-invoke-number-method recv key args))
((boolean? recv) (js-invoke-boolean-method recv key args))
(else
(let
((m (js-get-prop recv key)))
@@ -169,6 +292,9 @@
(= name "valueOf")
(= name "toLocaleString"))))
;; ── Arithmetic (JS rules) ─────────────────────────────────────────
;; JS `+`: if either operand is a string → string concat, else numeric.
(define
js-invoke-object-method
(fn
@@ -194,13 +320,6 @@
(define js-lower-case (fn (s) (js-case-loop s 0 "" false)))
;; parse a decimal number from a trimmed non-empty string.
;; s — source
;; i — cursor
;; acc — integer part so far (or total for decimals)
;; sign — 1 or -1
;; frac? — are we past the decimal point
;; fdiv — divisor used to scale fraction digits (only if frac?)
(define
js-case-loop
(fn
@@ -279,8 +398,6 @@
js-invoke-method-dyn
(fn (recv key args) (js-invoke-method recv key args)))
;; ── String coercion (ToString) ────────────────────────────────────
(define
js-call-plain
(fn
@@ -307,9 +424,7 @@
ret
obj))))))
;; ── Arithmetic (JS rules) ─────────────────────────────────────────
;; JS `+`: if either operand is a string → string concat, else numeric.
;; Bitwise + logical-not
(define
js-instanceof
(fn
@@ -338,6 +453,9 @@
((not (= (type-of p) "dict")) false)
(else (js-instanceof-walk p proto))))))))
;; ── Equality ──────────────────────────────────────────────────────
;; Strict equality (===): no coercion; js-undefined matches js-undefined.
(define
js-in
(fn
@@ -356,6 +474,9 @@
((dict-has? obj "__proto__") (js-in-walk (get obj "__proto__") skey))
(else false))))
;; Abstract equality (==): type coercion rules.
;; Simplified: number↔string coerce both to number; null == undefined;
;; everything else falls back to strict equality.
(define
Error
(fn
@@ -392,6 +513,11 @@
nil)
this))))
;; ── Relational comparisons ────────────────────────────────────────
;; Abstract relational comparison from ES5.
;; Numbers compare numerically; two strings compare lexicographically;
;; mixed types coerce both to numbers.
(define
RangeError
(fn
@@ -428,7 +554,6 @@
nil)
this))))
;; Bitwise + logical-not
(define
ReferenceError
(fn
@@ -459,18 +584,19 @@
(= t "component")
(and (= t "dict") (contains? (keys v) "__callable__"))))))
;; ── Equality ──────────────────────────────────────────────────────
;; Strict equality (===): no coercion; js-undefined matches js-undefined.
(define __js_proto_table__ (dict))
(define __js_next_id__ (dict))
;; Abstract equality (==): type coercion rules.
;; Simplified: number↔string coerce both to number; null == undefined;
;; everything else falls back to strict equality.
(dict-set! __js_next_id__ "n" 0)
;; ── Property access ───────────────────────────────────────────────
;; obj[key] or obj.key in JS. Handles:
;; • dicts keyed by string
;; • lists indexed by number (incl. .length)
;; • strings indexed by number (incl. .length)
;; Returns js-undefined if the key is absent.
(define
js-get-ctor-proto
(fn
@@ -482,11 +608,6 @@
(else
(let ((p (dict))) (begin (dict-set! __js_proto_table__ id p) p)))))))
;; ── Relational comparisons ────────────────────────────────────────
;; Abstract relational comparison from ES5.
;; Numbers compare numerically; two strings compare lexicographically;
;; mixed types coerce both to numbers.
(define
js-reset-ctor-proto!
(fn
@@ -501,6 +622,7 @@
(ctor proto)
(let ((id (js-ctor-id ctor))) (dict-set! __js_proto_table__ id proto))))
;; Setter — mutates the dict. Returns the new value (JS assignment yields rhs).
(define
js-ctor-id
(fn
@@ -510,6 +632,10 @@
(get ctor "__ctor_id__"))
(else (inspect ctor)))))
;; ── Short-circuit logical ops ─────────────────────────────────────
;; `a && b` in JS: if a is truthy return b else return a. The thunk
;; form defers evaluation of b — the transpiler passes (fn () b).
(define
js-typeof
(fn
@@ -539,6 +665,9 @@
((= v "") false)
(else true))))
;; ── console.log ───────────────────────────────────────────────────
;; Trivial bridge. `log-info` is available on OCaml; fall back to print.
(define
js-to-number
(fn
@@ -556,15 +685,9 @@
js-string-to-number
(fn (s) (cond ((= s "") 0) (else (js-parse-num-safe s)))))
;; ── Property access ───────────────────────────────────────────────
;; ── Math object ───────────────────────────────────────────────────
;; obj[key] or obj.key in JS. Handles:
;; • dicts keyed by string
;; • lists indexed by number (incl. .length)
;; • strings indexed by number (incl. .length)
;; Returns js-undefined if the key is absent.
(define js-parse-num-safe (fn (s) (cond (else (js-num-from-string s)))))
(define
js-num-from-string
(fn
@@ -574,18 +697,10 @@
(cond
((= trimmed "") 0)
(else (js-parse-decimal trimmed 0 0 1 false 0))))))
(define js-trim (fn (s) (js-trim-left (js-trim-right s))))
;; Setter — mutates the dict. Returns the new value (JS assignment yields rhs).
(define
js-trim-left
(fn (s) (let ((n (len s))) (js-trim-left-at s n 0))))
;; ── Short-circuit logical ops ─────────────────────────────────────
;; `a && b` in JS: if a is truthy return b else return a. The thunk
;; form defers evaluation of b — the transpiler passes (fn () b).
(define
js-trim-left-at
(fn
@@ -594,14 +709,9 @@
((>= i n) "")
((js-is-space? (char-at s i)) (js-trim-left-at s n (+ i 1)))
(else (substr s i n)))))
(define
js-trim-right
(fn (s) (let ((n (len s))) (js-trim-right-at s n))))
;; ── console.log ───────────────────────────────────────────────────
;; Trivial bridge. `log-info` is available on OCaml; fall back to print.
(define
js-trim-right-at
(fn
@@ -610,13 +720,9 @@
((<= n 0) "")
((js-is-space? (char-at s (- n 1))) (js-trim-right-at s (- n 1)))
(else (substr s 0 n)))))
(define
js-is-space?
(fn (c) (or (= c " ") (= c "\t") (= c "\n") (= c "\r"))))
;; ── Math object ───────────────────────────────────────────────────
(define
js-parse-decimal
(fn
@@ -648,7 +754,8 @@
sign
false
0)))
(else (* sign (if frac? (/ acc fdiv) acc)))))))
(else (* sign (if frac? (/ acc fdiv) acc))))))) ; deterministic placeholder for tests
(define
js-is-digit?
(fn
@@ -665,6 +772,11 @@
(= c "7")
(= c "8")
(= c "9")))))
;; The global object — lookup table for JS names that aren't in the
;; SX env. Transpiled idents look up locally first; globals here are a
;; fallback, but most slice programs reference `console`, `Math`,
;; `undefined` as plain symbols, which we bind as defines above.
(define
js-digit-val
(fn
@@ -681,6 +793,7 @@
((= c "8") 8)
((= c "9") 9)
(else 0))))
(define
js-to-string
(fn
@@ -693,9 +806,11 @@
((= (type-of v) "string") v)
((= (type-of v) "number") (js-number-to-string v))
(else (str v)))))
(define
js-template-concat
(fn (&rest parts) (js-template-concat-loop parts 0 "")))
(define
js-template-concat-loop
(fn
@@ -707,7 +822,9 @@
parts
(+ i 1)
(str acc (js-to-string (nth parts i)))))))
(define js-number-to-string (fn (n) (str n)))
(define
js-add
(fn
@@ -716,14 +833,11 @@
((or (= (type-of a) "string") (= (type-of b) "string"))
(str (js-to-string a) (js-to-string b)))
(else (+ (js-to-number a) (js-to-number b))))))
(define js-sub (fn (a b) (- (js-to-number a) (js-to-number b)))) ; deterministic placeholder for tests
(define js-sub (fn (a b) (- (js-to-number a) (js-to-number b))))
(define js-mul (fn (a b) (* (js-to-number a) (js-to-number b))))
;; The global object — lookup table for JS names that aren't in the
;; SX env. Transpiled idents look up locally first; globals here are a
;; fallback, but most slice programs reference `console`, `Math`,
;; `undefined` as plain symbols, which we bind as defines above.
(define js-div (fn (a b) (/ (js-to-number a) (js-to-number b))))
(define js-mod (fn (a b) (mod (js-to-number a) (js-to-number b))))

View File

@@ -1169,6 +1169,28 @@ cat > "$TMPFILE" << 'EPOCHS'
(epoch 3405)
(eval "(js-eval \"var arr2 = [1,2]; Array.prototype.push.call(arr2, 10, 20); arr2.length\")")
;; ── Phase 11.nummethod: .toString(), .toFixed() on numbers ───
(epoch 3600)
(eval "(js-eval \"(5).toString()\")")
(epoch 3601)
(eval "(js-eval \"(16).toString(16)\")")
(epoch 3602)
(eval "(js-eval \"(10).toString(2)\")")
(epoch 3603)
(eval "(js-eval \"(3.14).toFixed(1)\")")
(epoch 3604)
(eval "(js-eval \"(3.14).toFixed(2)\")")
(epoch 3605)
(eval "(js-eval \"(0).toFixed(3)\")")
(epoch 3606)
(eval "(js-eval \"(7).valueOf()\")")
(epoch 3607)
(eval "(js-eval \"true.toString()\")")
(epoch 3608)
(eval "(js-eval \"false.toString()\")")
(epoch 3609)
(eval "(js-eval \"(true).valueOf()\")")
;; ── Phase 11.arrlike: Array.prototype.* on {length, 0:..., 1:...} ──
(epoch 3500)
(eval "(js-eval \"var a = {length: 3, 0: 41, 1: 42, 2: 43}; Array.prototype.slice.call(a).length\")")
@@ -1819,6 +1841,18 @@ check 3403 "fn.call this-binding" '42'
check 3404 "fn.apply arg-unpack" '7'
check 3405 "Array.prototype.push.call arr" '4'
# ── Phase 11.nummethod: number/boolean methods ────────────────
check 3600 "(5).toString()" '"5"'
check 3601 "(16).toString(16)" '"10"'
check 3602 "(10).toString(2)" '"1010"'
check 3603 "(3.14).toFixed(1)" '"3.1"'
check 3604 "(3.14).toFixed(2)" '"3.14"'
check 3605 "(0).toFixed(3)" '"0.000"'
check 3606 "(7).valueOf()" '7'
check 3607 "true.toString()" '"true"'
check 3608 "false.toString()" '"false"'
check 3609 "(true).valueOf()" 'true'
# ── Phase 11.arrlike: array-like receivers on Array.prototype ─
check 3500 "slice.call arrLike length" '3'
check 3501 "slice.call arrLike join" '"41,42,43"'