eval refitted

This commit is contained in:
2026-05-27 15:28:23 +02:00
parent ad6d47023b
commit efe8e6769f
3 changed files with 196 additions and 24 deletions
+117 -6
View File
@@ -1,12 +1,62 @@
#lang racket/base
(require (for-syntax racket/base
(require racket/string
(for-syntax racket/base
racket/list
racket/match
racket/string))
(provide js js/expression)
;; Convert a Racket value produced by (eval <racket-expr>) inside a js form to
;; a JavaScript literal string. This is a Racket -> JavaScript interpolation
;; boundary, not JavaScript eval.
(define (jsmaker-runtime-escape-js-string s)
(define out (open-output-string))
(for ([ch (in-string s)])
(case ch
[(#\\) (display "\\\\" out)]
[(#\") (display "\\\"" out)]
[(#\newline) (display "\\n" out)]
[(#\return) (display "\\r" out)]
[(#\tab) (display "\\t" out)]
[else (display ch out)]))
(get-output-string out))
(define (jsmaker-runtime-js-string s)
(format "\"~a\"" (jsmaker-runtime-escape-js-string s)))
(define (jsmaker-runtime-key->js k)
(jsmaker-runtime-js-string
(cond [(symbol? k) (symbol->string k)]
[(keyword? k) (keyword->string k)]
[(string? k) k]
[else (format "~a" k)])))
(define (jsmaker-runtime-value->js v)
(cond
[(void? v) "undefined"]
[(eq? v #t) "true"]
[(eq? v #f) "false"]
[(number? v) (number->string v)]
[(string? v) (jsmaker-runtime-js-string v)]
[(char? v) (jsmaker-runtime-js-string (string v))]
[(symbol? v) (jsmaker-runtime-js-string (symbol->string v))]
[(keyword? v) (jsmaker-runtime-js-string (keyword->string v))]
[(null? v) "[]"]
[(pair? v)
(format "[~a]" (string-join (map jsmaker-runtime-value->js v) ", "))]
[(vector? v)
(format "[~a]" (string-join (map jsmaker-runtime-value->js (vector->list v)) ", "))]
[(hash? v)
(format "{~a}"
(string-join
(for/list ([(k val) (in-hash v)])
(format "~a: ~a" (jsmaker-runtime-key->js k) (jsmaker-runtime-value->js val)))
", "))]
[else
(error 'js "cannot interpolate Racket value as JavaScript literal: ~e" v)]))
;; The js macro translates a practical Racket-expression subset to JavaScript.
;; It is intentionally syntax-driven: the Racket expressions are not evaluated.
@@ -201,6 +251,44 @@
(set! tmp-counter (add1 tmp-counter))
(format "__~a_~a" prefix tmp-counter))
;; Runtime interpolation escape hatch. Before compiling the syntax to a
;; datum, the public macros replace each (eval <racket-expr>) subform by a
;; unique placeholder symbol. The generated macro result is then a Racket
;; expression that replaces those placeholders by JavaScript literals computed
;; from <racket-expr> in the use-site lexical context. This is intentionally
;; different from JavaScript eval, which remains available as
;; (send window eval ...) or by binding/calling another identifier.
(define (compile-racket-eval expr)
(fail "internal eval placeholder; public macro should rewrite eval first" expr))
(define (replace-racket-evals stx)
(define counter 0)
(define evals '())
(define (fresh-eval-placeholder)
(set! counter (add1 counter))
(string->symbol (format "__rkt_eval_slot_~a__" counter)))
(define (walk d)
(match d
[(list 'quote _) d]
[(list 'quasiquote _) d]
[(list 'eval expr)
(define ph (fresh-eval-placeholder))
(set! evals (append evals (list (list (symbol->string ph) expr))))
ph]
[(? pair?) (cons (walk (car d)) (walk (cdr d)))]
[(? vector?) (list->vector (map walk (vector->list d)))]
[_ d]))
(values (walk (syntax->datum stx)) evals))
(define (wrap-compiled-js stx compiled evals)
(for/fold ([acc #`#,compiled]) ([ev (in-list evals)])
(define placeholder (first ev))
(define racket-expr-datum (second ev))
#`(string-replace #,acc
#,placeholder
(jsmaker-runtime-value->js
#,(datum->syntax stx racket-expr-datum)))))
;; Racket conditionals treat only #f as false. JavaScript would also
;; reject 0, "", null and undefined in condition position, so every
;; Racket test is compiled through this helper.
@@ -384,9 +472,22 @@
(define content
(case kind
[(let)
;; Evaluate all RHS expressions before introducing the JS let bindings.
;; This avoids JavaScript TDZ bugs for Racket code such as
;; (let ([x x]) ...), where the RHS must see the outer x.
;; Racket let evaluates all RHS expressions before introducing the new
;; bindings. The common case can still be emitted directly when no RHS
;; mentions one of the newly-bound identifiers. If it does, use temps
;; to avoid JavaScript TDZ bugs for code such as (let ([x x]) ...),
;; where the RHS must see the outer x.
(define ids (map binding-id bindings))
(define (mentions-id? x id)
(cond
[(symbol? x) (eq? x id)]
[(pair? x) (or (mentions-id? (car x) id) (mentions-id? (cdr x) id))]
[(vector? x) (for/or ([e (in-vector x)]) (mentions-id? e id))]
[else #f]))
(define (mentions-any-bound-id? rhs)
(for/or ([id (in-list ids)]) (mentions-id? rhs id)))
(cond
[(for/or ([b (in-list bindings)]) (mentions-any-bound-id? (binding-rhs b)))
(define tmps (for/list ([_ (in-list bindings)]) (fresh "let_value")))
(define rhs-stmts
(for/list ([tmp (in-list tmps)] [b (in-list bindings)])
@@ -395,6 +496,12 @@
(for/list ([tmp (in-list tmps)] [b (in-list bindings)])
(format "let ~a = ~a;" (id->js (binding-id b)) tmp)))
(join-lines (append rhs-stmts (list (block (join-lines (append bind-stmts (list (body-code))))))))]
[else
(join-lines
(append
(for/list ([b (in-list bindings)])
(format "let ~a = ~a;" (id->js (binding-id b)) (compile-expr (binding-rhs b))))
(list (body-code))))])]
[(let*)
;; Emit the common case directly:
;; (let* ([x 10] [y (+ x x)]) body)
@@ -1280,6 +1387,8 @@ if (~a !== false) return ~a;" tmp (compile-expr arg) tmp tmp)))
(format "(~a = ~a)" (compile-assignment-target target) (compile-expr rhs))]
[(list 'return) "undefined"]
[(list 'return e) (compile-expr e)]
[(list 'eval racket-expr)
(compile-racket-eval racket-expr)]
[(list f args ...) (compile-call f args)]
[_ (literal->js d)]))
@@ -1289,9 +1398,11 @@ if (~a !== false) return ~a;" tmp (compile-expr arg) tmp tmp)))
(define-syntax (js stx)
(syntax-case stx ()
[(_ form ...)
(datum->syntax stx (compile-top (syntax->datum #'(form ...))))]))
(let-values ([(clean-datum evals) (replace-racket-evals #'(form ...))])
(wrap-compiled-js stx (compile-top clean-datum) evals))]))
(define-syntax (js/expression stx)
(syntax-case stx ()
[(_ form)
(datum->syntax stx (compile-expr (syntax->datum #'form)))]))
(let-values ([(clean-datum evals) (replace-racket-evals #'form)])
(wrap-compiled-js stx (compile-expr clean-datum) evals))]))
+53 -10
View File
@@ -83,8 +83,11 @@ self-call.
The generator is best understood as a source-to-source translator over syntax.
The input is converted to datum form and matched against the supported surface
language. The Racket code is not evaluated. This has a few important
consequences:
language. The Racket code is normally not evaluated. The deliberate exception
is @racket[(eval racket-expr)], which interpolates a Racket value into the
JavaScript source text. The expression is evaluated in the lexical context of
the @racket[js] or @racket[js/expression] use and its value is emitted as a
JavaScript literal. This has a few important consequences:
@compact-items[
@item{Only forms known to js-maker are translated specially. Unknown calls are
@@ -100,6 +103,46 @@ consequences:
helper functions/IIFEs to preserve semantics.}
]
@subsection{Racket value interpolation with eval}
The form @racket[(eval racket-expr)] is an interpolation escape hatch inherited
from the original transformer. It evaluates @racket[racket-expr] as Racket in
the use-site lexical context and then splices the resulting value into the
JavaScript source as a literal. This makes surrounding Racket bindings visible
to the interpolation expression.
@(rkt+js
#<<RKT
(let ([x 10]
[y 20])
(js (let ([a (eval (* x y))])
(return (* a a)))))
RKT
#<<JS
{
let a = 200;
return (a * a);
}
JS
)
@(rkt+js
#<<RKT
(js/expression (array (eval (+ 1 2))
(eval (string-append "a" "b"))))
RKT
#<<JS
[3, "ab"]
JS
)
This is not JavaScript @tt{eval}. To call JavaScript @tt{eval}, call the
JavaScript function explicitly, for example @racket[(send window eval "1 + 2")].
Racket-side @racket[eval] is best used for constants, generated literal data,
and small configuration values that are known while the JavaScript source is
being constructed. It should not be used for run-time browser state, DOM access,
or user input.
@section{Supported expression and statement forms}
The following Racket forms are supported as either expressions, statements, or
@@ -116,6 +159,7 @@ both, depending on context:
@item{@racket[when], @racket[unless], @racket[while], @racket[for],
@racket[for/list], @racket[for/vector], and @racket[for/fold].}
@item{@racket[with-handlers] for the generic @racket[exn?] case.}
@item{@racket[(eval racket-expr)] for Racket-side value interpolation into JavaScript literals.}
@item{Interop forms such as @racket[send], @racket[new], @racket[js-ref],
@racket[js-dot], @racket[set-prop!], @racket[delete-prop!],
@racket[js-delete], @racket[array], @racket[object],
@@ -732,14 +776,13 @@ harness rather than hiding raw JavaScript inside the feature implementation.
@section{Further examples}
The companion document @filepath{scrbl/usecases.scrbl} contains practical
examples such as DOM manipulation, @tt{Set}, currying, object destructuring,
timers, @tt{fetch}, sorting, binary search, and map-based counting. Each use
case shows the Racket/js-maker source, representative JavaScript output, and the
behavior tested by the regression suite.
The companion @hyperlink["../usecases/index.html"]{use-case manual} contains
practical examples such as DOM manipulation, @tt{Set}, currying, object
destructuring, timers, @tt{fetch}, sorting, binary search, and map-based
counting. Each use case shows the Racket/js-maker source, representative
JavaScript output, and the behavior tested by the regression suite.
When the documentation is rendered with @exec{raco scribble}, the use-case
manual is normally emitted as a separate document next to this one. The explicit
file name is used here instead of a cross-document reference because the latter
The source file for that companion manual is @filepath{scrbl/usecases.scrbl}.
The relative link above is used instead of @racket[other-doc] because the latter
can render as an unresolved @tt{(part ... "top")} tag when the two documents are
built outside Racket's installed documentation index.
+18
View File
@@ -63,6 +63,24 @@
(js-expression-test 'for-fold (js/expression (for/fold ([s 0]) ([x (in-list (list 1 2 3))]) (+ x s))) "6")
(js-expression-test 'map-filter (js/expression (filter (lambda (x) (> x 2)) (map (lambda (x) (+ x 1)) (list 1 2 3)))) "[3,4]")
(js-expression-test 'hash-ref (js/expression (hash-ref (hash 'a 1 'b 2) 'b)) "2")
(js-expression-test 'compile-time-eval-var
(let ((x 10)
(y 20))
(js/expression (let ((a (eval (* x y))))
(+ a a))))
"400")
(js-expression-test 'compile-time-eval-number
(js/expression (eval (+ 1 2)))
"3")
(js-expression-test 'compile-time-eval-data
(js/expression (array (eval (list 1 2 3))
(eval (string-append "a" "b"))))
"[[1,2,3],\"ab\"]")
(let ([x 10]
[y 20])
(js-expression-test 'runtime-eval-lexical-let
(js/expression (let ([a (eval (* x y))]) (* a a)))
"40000"))
(js-expression-test 'substring (js/expression (substring "abcdef" 1 4)) "\"bcd\"")
(js-expression-test 'equal-list (js/expression (equal? (list 1 2) (list 1 2))) "true")
(js-expression-test 'cond-test-only (js/expression (cond [0] [else 2])) "0")