From e89ab88670704e4f8a7c93a89afeab8052d04be0 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Thu, 28 May 2026 00:07:21 +0200 Subject: [PATCH] eval/inject improved; testing won t fail on non existing javascript engine --- README.md | 14 +++-- main.rkt | 70 ++++++++++++++++------- scrbl/js-maker.scrbl | 91 +++++++++++++++++++----------- testing/jsmaker-regression.rkt | 47 +++++++++++++++ testing/jsmaker-test-framework.rkt | 24 +++++--- testing/jsmaker-test-runner.rkt | 5 +- 6 files changed, 185 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 8b68941..7ff332a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/main.rkt b/main.rkt index 6bf1a6c..69fc48f 100644 --- a/main.rkt +++ b/main.rkt @@ -8,9 +8,10 @@ (provide js js/expression) -;; Convert a Racket value produced by (eval ) 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 ) or the +;; historical alias (eval ) 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 ) subform by a - ;; unique placeholder symbol. The generated macro result is then a Racket + ;; datum, the public macros replace each (inject ) subform by a + ;; unique placeholder symbol. The historical alias (eval ) is + ;; kept for compatibility. The generated macro result is then a Racket ;; expression that replaces those placeholders by JavaScript literals computed ;; from 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) diff --git a/scrbl/js-maker.scrbl b/scrbl/js-maker.scrbl index 6d7961b..356eef8 100644 --- a/scrbl/js-maker.scrbl +++ b/scrbl/js-maker.scrbl @@ -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 #<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") diff --git a/testing/jsmaker-test-framework.rkt b/testing/jsmaker-test-framework.rkt index 7522bfd..1b5f427 100644 --- a/testing/jsmaker-test-framework.rkt +++ b/testing/jsmaker-test-framework.rkt @@ -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.")))])) diff --git a/testing/jsmaker-test-runner.rkt b/testing/jsmaker-test-runner.rkt index 793cc51..0ebd09e 100644 --- a/testing/jsmaker-test-runner.rkt +++ b/testing/jsmaker-test-runner.rkt @@ -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))