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
+125 -14
View File
@@ -1,12 +1,62 @@
#lang racket/base #lang racket/base
(require (for-syntax racket/base (require racket/string
(for-syntax racket/base
racket/list racket/list
racket/match racket/match
racket/string)) racket/string))
(provide js js/expression) (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. ;; The js macro translates a practical Racket-expression subset to JavaScript.
;; It is intentionally syntax-driven: the Racket expressions are not evaluated. ;; It is intentionally syntax-driven: the Racket expressions are not evaluated.
@@ -201,6 +251,44 @@
(set! tmp-counter (add1 tmp-counter)) (set! tmp-counter (add1 tmp-counter))
(format "__~a_~a" prefix 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 ;; Racket conditionals treat only #f as false. JavaScript would also
;; reject 0, "", null and undefined in condition position, so every ;; reject 0, "", null and undefined in condition position, so every
;; Racket test is compiled through this helper. ;; Racket test is compiled through this helper.
@@ -384,17 +472,36 @@
(define content (define content
(case kind (case kind
[(let) [(let)
;; Evaluate all RHS expressions before introducing the JS let bindings. ;; Racket let evaluates all RHS expressions before introducing the new
;; This avoids JavaScript TDZ bugs for Racket code such as ;; bindings. The common case can still be emitted directly when no RHS
;; (let ([x x]) ...), where the RHS must see the outer x. ;; mentions one of the newly-bound identifiers. If it does, use temps
(define tmps (for/list ([_ (in-list bindings)]) (fresh "let_value"))) ;; to avoid JavaScript TDZ bugs for code such as (let ([x x]) ...),
(define rhs-stmts ;; where the RHS must see the outer x.
(for/list ([tmp (in-list tmps)] [b (in-list bindings)]) (define ids (map binding-id bindings))
(format "const ~a = ~a;" tmp (compile-expr (binding-rhs b))))) (define (mentions-id? x id)
(define bind-stmts (cond
(for/list ([tmp (in-list tmps)] [b (in-list bindings)]) [(symbol? x) (eq? x id)]
(format "let ~a = ~a;" (id->js (binding-id b)) tmp))) [(pair? x) (or (mentions-id? (car x) id) (mentions-id? (cdr x) id))]
(join-lines (append rhs-stmts (list (block (join-lines (append bind-stmts (list (body-code))))))))] [(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)])
(format "const ~a = ~a;" tmp (compile-expr (binding-rhs b)))))
(define bind-stmts
(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*) [(let*)
;; Emit the common case directly: ;; Emit the common case directly:
;; (let* ([x 10] [y (+ x x)]) body) ;; (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))] (format "(~a = ~a)" (compile-assignment-target target) (compile-expr rhs))]
[(list 'return) "undefined"] [(list 'return) "undefined"]
[(list 'return e) (compile-expr e)] [(list 'return e) (compile-expr e)]
[(list 'eval racket-expr)
(compile-racket-eval racket-expr)]
[(list f args ...) (compile-call f args)] [(list f args ...) (compile-call f args)]
[_ (literal->js d)])) [_ (literal->js d)]))
@@ -1289,9 +1398,11 @@ if (~a !== false) return ~a;" tmp (compile-expr arg) tmp tmp)))
(define-syntax (js stx) (define-syntax (js stx)
(syntax-case stx () (syntax-case stx ()
[(_ form ...) [(_ 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) (define-syntax (js/expression stx)
(syntax-case stx () (syntax-case stx ()
[(_ form) [(_ 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 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 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 language. The Racket code is normally not evaluated. The deliberate exception
consequences: 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[ @compact-items[
@item{Only forms known to js-maker are translated specially. Unknown calls are @item{Only forms known to js-maker are translated specially. Unknown calls are
@@ -100,6 +103,46 @@ consequences:
helper functions/IIFEs to preserve semantics.} 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} @section{Supported expression and statement forms}
The following Racket forms are supported as either expressions, statements, or 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], @item{@racket[when], @racket[unless], @racket[while], @racket[for],
@racket[for/list], @racket[for/vector], and @racket[for/fold].} @racket[for/list], @racket[for/vector], and @racket[for/fold].}
@item{@racket[with-handlers] for the generic @racket[exn?] case.} @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], @item{Interop forms such as @racket[send], @racket[new], @racket[js-ref],
@racket[js-dot], @racket[set-prop!], @racket[delete-prop!], @racket[js-dot], @racket[set-prop!], @racket[delete-prop!],
@racket[js-delete], @racket[array], @racket[object], @racket[js-delete], @racket[array], @racket[object],
@@ -732,14 +776,13 @@ harness rather than hiding raw JavaScript inside the feature implementation.
@section{Further examples} @section{Further examples}
The companion document @filepath{scrbl/usecases.scrbl} contains practical The companion @hyperlink["../usecases/index.html"]{use-case manual} contains
examples such as DOM manipulation, @tt{Set}, currying, object destructuring, practical examples such as DOM manipulation, @tt{Set}, currying, object
timers, @tt{fetch}, sorting, binary search, and map-based counting. Each use destructuring, timers, @tt{fetch}, sorting, binary search, and map-based
case shows the Racket/js-maker source, representative JavaScript output, and the counting. Each use case shows the Racket/js-maker source, representative
behavior tested by the regression suite. JavaScript output, and the behavior tested by the regression suite.
When the documentation is rendered with @exec{raco scribble}, the use-case The source file for that companion manual is @filepath{scrbl/usecases.scrbl}.
manual is normally emitted as a separate document next to this one. The explicit The relative link above is used instead of @racket[other-doc] because the latter
file name is used here instead of a cross-document reference because the latter
can render as an unresolved @tt{(part ... "top")} tag when the two documents are can render as an unresolved @tt{(part ... "top")} tag when the two documents are
built outside Racket's installed documentation index. 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 '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 '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 '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 '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 '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") (js-expression-test 'cond-test-only (js/expression (cond [0] [else 2])) "0")