eval/inject improved; testing won t fail on non existing javascript engine

This commit is contained in:
2026-05-28 00:07:21 +02:00
parent 8fe47a7ed4
commit e89ab88670
6 changed files with 185 additions and 66 deletions
+10 -4
View File
@@ -54,8 +54,10 @@ The test framework looks for JavaScript engines such as `node`, `deno`,
or when `JSMAKER_BROWSER_FALLBACK=1` is set.
When no JavaScript engine is available, the tests generate the JavaScript test
files and print warnings, but do not fail unless `JSMAKER_REQUIRE_ENGINE` or
`JSMAKER_REQUIRE_NODE` is set.
files and use an explicit `non-failing-javascript-stub`. The stub prints notes
to stdout, does not execute the generated JavaScript, and succeeds unless
`JSMAKER_REQUIRE_ENGINE` or `JSMAKER_REQUIRE_NODE` is set. This avoids package
server failures caused solely by a missing JavaScript runtime.
Useful environment variables:
@@ -105,8 +107,12 @@ Belangrijk:
This build includes the `with-handlers` callee-position fix for inline lambda
handlers, including rest-argument handlers such as `(lambda args ...)`. It also
adds a Racket-like division-by-zero runtime check for `/`, so the generic
`exn?` handler subset can catch `(/ 10 0)`.
fixes top-level `js` statement-context handling for `with-handlers`, so a
side-effecting catch handler does not prematurely return from the surrounding
JavaScript wrapper. Use `js/expression` when the value of a `with-handlers`
form itself is needed. The build also adds a Racket-like division-by-zero
runtime check for `/`, so the generic `exn?` handler subset can catch
`(/ 10 0)`.
## JavaScript use case demos
+51 -19
View File
@@ -8,9 +8,10 @@
(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.
;; Convert a Racket value produced by (inject <racket-expr>) or the
;; historical alias (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)])
@@ -261,8 +262,9 @@
(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
;; datum, the public macros replace each (inject <racket-expr>) subform by a
;; unique placeholder symbol. The historical alias (eval <racket-expr>) is
;; kept for compatibility. 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
@@ -276,27 +278,49 @@
(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 (syntax-head-symbol x)
(define xs (syntax->list x))
(and xs
(pair? xs)
(identifier? (car xs))
(syntax-e (car xs))))
(define (walk-pair p)
(cond
[(null? p) '()]
[(pair? p) (cons (walk (car p)) (walk-pair (cdr p)))]
[else (walk p)]))
(define (walk x)
(define e (if (syntax? x) (syntax-e x) x))
(cond
[(syntax? x)
(case (syntax-head-symbol x)
[(quote quasiquote) (syntax->datum x)]
[(inject eval)
(define xs (syntax->list x))
(cond
[(and xs (= (length xs) 2))
(define ph (fresh-eval-placeholder))
(set! evals (append evals (list (list (symbol->string ph) (cadr xs)))))
ph]
[else (walk-pair e)])]
[else
(cond
[(pair? e) (walk-pair e)]
[(vector? e) (list->vector (map walk (vector->list e)))]
[else e])])]
[(pair? e) (walk-pair e)]
[(vector? e) (list->vector (map walk (vector->list e)))]
[else e]))
(values (walk 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))
(define racket-expr-stx (second ev))
#`(string-replace #,acc
#,placeholder
(jsmaker-runtime-value->js
#,(datum->syntax stx racket-expr-datum)))))
#,racket-expr-stx))))
;; Racket conditionals treat only #f as false. JavaScript would also
;; reject 0, "", null and undefined in condition position, so every
@@ -1408,6 +1432,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 'inject racket-expr)
(compile-racket-eval racket-expr)]
[(list 'eval racket-expr)
(compile-racket-eval racket-expr)]
[(list f args ...) (compile-call f args)]
@@ -1424,6 +1450,12 @@ if (~a !== false) return ~a;" tmp (compile-expr arg) tmp tmp)))
[(list 'delete-prop! _ ...) #f]
[(list 'while _ ...) #f]
[(list 'for _ ...) #f]
;; Top-level js is statement/program output. with-handlers may contain
;; a handler used only for side effects; compiling it as an implicit
;; tail return would emit return statements inside the generated
;; try/catch and prematurely leave the caller's wrapper. Use
;; js/expression when the value of with-handlers is needed.
[(list 'with-handlers _ ...) #f]
[_ #t]))
(define (compile-top forms)
+57 -34
View File
@@ -22,10 +22,13 @@
@bold{js-maker} is a small, syntax-driven JavaScript generator for writing a
practical JavaScript subset in Racket notation. It provides two macros,
@racket[js] and @racket[js/expression]. Both macros run at expansion time and
return JavaScript source code as a string. The generated JavaScript can then be
embedded in a page, written to a file, tested with Node or another JavaScript
engine, or used as part of a larger code-generation workflow.
@racket[js] and @racket[js/expression]. In the ordinary case the macros
expand to a string containing JavaScript source code. When the source contains
@racket[(inject racket-expr)] or its historical alias @racket[(eval racket-expr)],
the macros instead expand to a Racket expression that computes the JavaScript
source string at run time. The generated JavaScript can then be embedded in a
page, written to a file, tested with Node or another JavaScript engine, or used
as part of a larger code-generation workflow.
The package is deliberately not a full Racket compiler. It recognizes a
well-defined set of Racket-like forms and maps them to JavaScript while trying
@@ -60,9 +63,12 @@ Inside function bodies, the last expression is returned automatically unless it
is already a statement form such as @racket[return], @racket[define],
@racket[set!], @racket[while], or @racket[for]. At the top level of a
@racket[js] form, js-maker also returns the value of a final value-producing
form, such as @racket[let], @racket[begin], @racket[if], or
@racket[with-handlers]. This makes @racket[js] output suitable as the body of a
WebView @tt{runJavaScript} wrapper function.
form, such as @racket[let], @racket[begin], or @racket[if]. This makes
@racket[js] output suitable as the body of a WebView @tt{runJavaScript}
wrapper function. A top-level @racket[with-handlers] is treated as a statement
so that a catch handler used for side effects does not prematurely return from
the surrounding wrapper. Use @racket[js/expression] when the value of a
@racket[with-handlers] form itself is needed.
For example:
@@ -71,7 +77,7 @@ For example:
(displayln
(js
(let ([el (send document getElementById 'test)])
(set! (js-dot el innerHTML) (eval html))
(set! (js-dot el innerHTML) (inject html))
#t))))
]
@@ -107,12 +113,14 @@ self-call.
@section{Mental model}
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
Most input is converted to datum form and matched against the supported surface
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:
is @racket[(inject racket-expr)], and the compatible older spelling
@racket[(eval racket-expr)]. These forms mark a Racket expression whose value
must be serialized as a JavaScript literal while the generated source string is
being constructed. The expression is evaluated in the lexical context of the
@racket[js] or @racket[js/expression] use. This has a few important
consequences:
@compact-items[
@item{Only forms known to js-maker are translated specially. Unknown calls are
@@ -128,19 +136,24 @@ JavaScript literal. This has a few important consequences:
helper functions/IIFEs to preserve semantics.}
]
@subsection{Racket value interpolation with eval}
@subsection{Racket value interpolation with inject}
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.
The form @racket[(inject racket-expr)] is the preferred interpolation escape
hatch. 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.
The older spelling @racket[(eval racket-expr)] is still accepted as an alias for
compatibility with existing code. New code should prefer @racket[inject],
because the form is not JavaScript @tt{eval} and does not evaluate JavaScript
source text.
@(rkt+js
#<<RKT
(let ([x 10]
[y 20])
(js (let ([a (eval (* x y))])
(js (let ([a (inject (* x y))])
(return (* a a)))))
RKT
#<<JS
@@ -153,20 +166,26 @@ JS
@(rkt+js
#<<RKT
(js/expression (array (eval (+ 1 2))
(eval (string-append "a" "b"))))
(js/expression (array (inject (+ 1 2))
(inject (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.
Neither @racket[inject] nor its alias @racket[eval] is JavaScript @tt{eval}.
To call JavaScript @tt{eval}, call the JavaScript function explicitly, for
example @racket[(send window eval "1 + 2")]. Racket-side interpolation 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 untrusted user input.
The name @racket[inject] was chosen over a name such as @racket[subst] because
it is not raw textual substitution. js-maker serializes the Racket value to a
JavaScript literal, including string escaping, booleans, numbers, lists, vectors,
hashes, symbols, keywords, characters, @racket[#f], @racket[#t], @racket[null],
and @racket[(void)].
@section{Supported expression and statement forms}
@@ -184,7 +203,9 @@ 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{@racket[(inject racket-expr)] for Racket-side value interpolation into
JavaScript literals; @racket[(eval racket-expr)] is accepted as a
compatibility alias.}
@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],
@@ -728,11 +749,13 @@ with a JavaScript executor. The executor module searches for engines such as
Node, Deno, Bun, QuickJS, V8 @tt{d8}, JavaScriptCore @tt{jsc}, SpiderMonkey
@tt{js}, and an optional Chromium fallback. Node is the preferred default.
If no JavaScript engine is available, tests are generated but execution is
skipped with clear warnings and a successful exit status. This is intentional
so package tests do not fail merely because a JavaScript runtime is missing.
Set @tt{JSMAKER_REQUIRE_ENGINE=1} or @tt{JSMAKER_REQUIRE_NODE=1} to make a
missing engine a hard failure.
If no JavaScript engine is available, tests are generated and the framework
uses an explicit @tt{non-failing-javascript-stub}. The stub prints notes to
stdout, does not execute the generated JavaScript, and exits successfully.
This is intentional so package tests do not fail merely because a JavaScript
runtime is missing, especially in package-server environments. Set
@tt{JSMAKER_REQUIRE_ENGINE=1} or @tt{JSMAKER_REQUIRE_NODE=1} to make a missing
engine a hard failure.
Useful commands are:
+47
View File
@@ -21,6 +21,48 @@
(unless (regexp-match? #rx"__cmp" effectful-chain)
(error 'jsmaker-regression "effectful chained comparison should use temporaries, got: ~a" effectful-chain))
(define-syntax with-id->el
(syntax-rules ()
((_ id el expr)
(js (let ((el (send document getElementById (eval id))))
expr)))))
(define (runtime-eval-macro-function id html)
(with-id->el id el
(begin
(set! (js-dot el innerHTML) (eval html))
#t)))
(define runtime-eval-macro-program
(runtime-eval-macro-function 't "BEE"))
(unless (and (regexp-match? #rx"getElementById\\(\"t\"\\)" runtime-eval-macro-program)
(regexp-match? #rx"innerHTML = \"BEE\"" runtime-eval-macro-program)
(regexp-match? #rx"return true;" runtime-eval-macro-program))
(error 'jsmaker-regression
"runtime eval inside a user macro should keep use-site lexical bindings, got: ~a"
runtime-eval-macro-program))
(define-syntax with-id->el/inject
(syntax-rules ()
((_ id el expr)
(js (let ((el (send document getElementById (inject id))))
expr)))))
(define (runtime-inject-macro-function id html)
(with-id->el/inject id el
(begin
(set! (js-dot el innerHTML) (inject html))
#t)))
(define runtime-inject-macro-program
(runtime-inject-macro-function 't "BEE"))
(unless (and (regexp-match? #rx"getElementById\\(\"t\"\\)" runtime-inject-macro-program)
(regexp-match? #rx"innerHTML = \"BEE\"" runtime-inject-macro-program)
(regexp-match? #rx"return true;" runtime-inject-macro-program))
(error 'jsmaker-regression
"runtime inject inside a user macro should keep use-site lexical bindings, got: ~a"
runtime-inject-macro-program))
(define simple-let-star
(js (let* ((x 10)
(y (+ x x)))
@@ -75,6 +117,11 @@
(js-expression-test 'runtime-eval-lexical-let
(js/expression (let ([a (eval (* x y))]) (* a a)))
"40000"))
(let ([x 10]
[y 20])
(js-expression-test 'runtime-inject-lexical-let
(js/expression (let ([a (inject (* 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")
+17 -7
View File
@@ -8,8 +8,12 @@
js-program-test
write-js-test-file
run-jsmaker-regression
notice-line
warning-line)
(define (notice-line who fmt . args)
(displayln (string-append "NOTE: " (apply format fmt args))))
(define (warning-line who fmt . args)
(displayln (string-append "WARNING: " (apply format fmt args))
(current-error-port)))
@@ -79,10 +83,16 @@
(error who "JavaScript regression failed with ~a; see ~a"
(js-engine-name engine) js-path))]
[else
(warning-line who "No JavaScript engine was found; regression tests were generated but not executed.")
(warning-line who "Generated JavaScript test file: ~a" js-path)
(warning-line who "Tried engines: ~a" (string-join (map symbol->string (known-js-engine-names)) ", "))
(warning-line who "Set JSMAKER_ENGINE to auto/node/deno/bun/qjs/d8/jsc/js/chromium or set JSMAKER_ENGINE_PATH.")
(warning-line who "For backwards compatibility, JSMAKER_NODE can point to a Node executable.")
(when (or (getenv "JSMAKER_REQUIRE_ENGINE") (getenv "JSMAKER_REQUIRE_NODE"))
(error who "JavaScript engine required by environment setting"))]))
(if (or (getenv "JSMAKER_REQUIRE_ENGINE") (getenv "JSMAKER_REQUIRE_NODE"))
(begin
(warning-line who "No JavaScript engine was found; regression tests were generated but not executed.")
(warning-line who "Generated JavaScript test file: ~a" js-path)
(warning-line who "Tried engines: ~a" (string-join (map symbol->string (known-js-engine-names)) ", "))
(warning-line who "Set JSMAKER_ENGINE to auto/node/deno/bun/qjs/d8/jsc/js/chromium or set JSMAKER_ENGINE_PATH.")
(warning-line who "For backwards compatibility, JSMAKER_NODE can point to a Node executable.")
(error who "JavaScript engine required by environment setting"))
(begin
(notice-line who "No JavaScript engine was found; using non-failing-javascript-stub.")
(notice-line who "Generated JavaScript test file: ~a" js-path)
(notice-line who "Generated tests were not executed by a JavaScript runtime.")
(notice-line who "Set JSMAKER_REQUIRE_ENGINE=1 to make a missing JavaScript engine fail.")))]))
+3 -2
View File
@@ -6,7 +6,8 @@
;; Compatibility wrapper for the older single-engine test runner name.
;; It now delegates to the generic framework/executor layer. When no engine
;; is available, the framework generates the JavaScript test file and reports a
;; skip/warning unless JSMAKER_REQUIRE_ENGINE or JSMAKER_REQUIRE_NODE is set.
;; is available, the framework generates the JavaScript test file and uses an
;; explicit non-failing-javascript-stub unless JSMAKER_REQUIRE_ENGINE or
;; JSMAKER_REQUIRE_NODE is set.
(define (run-jsmaker-node-regression who tests js-path)
(run-jsmaker-regression who tests js-path))