From d824583fc9ab7f7512a8d68e8861883a78cfa122 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Tue, 2 Jun 2026 14:46:41 +0000 Subject: [PATCH 01/21] Add builtin taggedTemplate type and Stdlib.TaggedTemplate module Introduce a predefined global `taggedTemplate<'param, 'output>` type (arity 2, mirroring `promise`/`dict` in predef.ml) so that tagged-templateness can live on the type of a value rather than on an external binding site. Add the thin `Stdlib.TaggedTemplate` module aliasing it as `t<'param, 'output>` and exposing `make`, which lifts a plain ReScript tag function `(array, array<'param>) => 'output` into the type by adapting the variadic JS tag-call convention. Part of the first-class tagged-template work (rescript-lang/rescript#8415). --- compiler/ml/predef.ml | 20 +++++++- compiler/ml/predef.mli | 2 + packages/@rescript/runtime/Stdlib.res | 1 + .../runtime/Stdlib_TaggedTemplate.res | 6 +++ .../runtime/Stdlib_TaggedTemplate.resi | 48 +++++++++++++++++++ packages/@rescript/runtime/lib/es6/Stdlib.mjs | 5 +- .../runtime/lib/es6/Stdlib_TaggedTemplate.mjs | 9 ++++ packages/@rescript/runtime/lib/js/Stdlib.cjs | 5 +- .../runtime/lib/js/Stdlib_TaggedTemplate.cjs | 7 +++ packages/artifacts.json | 8 ++++ 10 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 packages/@rescript/runtime/Stdlib_TaggedTemplate.res create mode 100644 packages/@rescript/runtime/Stdlib_TaggedTemplate.resi create mode 100644 packages/@rescript/runtime/lib/es6/Stdlib_TaggedTemplate.mjs create mode 100644 packages/@rescript/runtime/lib/js/Stdlib_TaggedTemplate.cjs diff --git a/compiler/ml/predef.ml b/compiler/ml/predef.ml index fb5e4271128..2525c1128d6 100644 --- a/compiler/ml/predef.ml +++ b/compiler/ml/predef.ml @@ -65,14 +65,16 @@ and ident_unknown = ident_create "unknown" and ident_promise = ident_create "promise" +and ident_tagged_template = ident_create "taggedTemplate" + type test = For_sure_yes | For_sure_no | NA let type_is_builtin_path_but_option (p : Path.t) : test = match p with | Pident {stamp} when stamp = ident_option.stamp -> For_sure_no | Pident {stamp} when stamp = ident_unit.stamp -> For_sure_no - | Pident {stamp} when stamp >= ident_int.stamp && stamp <= ident_promise.stamp - -> + | Pident {stamp} + when stamp >= ident_int.stamp && stamp <= ident_tagged_template.stamp -> For_sure_yes | _ -> NA @@ -112,6 +114,8 @@ and path_extension_constructor = Pident ident_extension_constructor and path_promise = Pident ident_promise +and path_tagged_template = Pident ident_tagged_template + let type_int = newgenty (Tconstr (path_int, [], ref Mnil)) and type_char = newgenty (Tconstr (path_char, [], ref Mnil)) @@ -148,6 +152,9 @@ and type_unknown = newgenty (Tconstr (path_unkonwn, [], ref Mnil)) and type_extension_constructor = newgenty (Tconstr (path_extension_constructor, [], ref Mnil)) +and type_tagged_template t1 t2 = + newgenty (Tconstr (path_tagged_template, [t1; t2], ref Mnil)) + let ident_match_failure = ident_create_predef_exn "Match_failure" and ident_invalid_argument = ident_create_predef_exn "Invalid_argument" @@ -363,6 +370,14 @@ let common_initial_env add_type add_extension empty_env = type_arity = 1; type_variance = [Variance.covariant]; } + and decl_tagged_template = + let tvar1, tvar2 = (newgenvar (), newgenvar ()) in + { + decl_abstr with + type_params = [tvar1; tvar2]; + type_arity = 2; + type_variance = [Variance.full; Variance.full]; + } in let add_exception id l = @@ -397,6 +412,7 @@ let common_initial_env add_type add_extension empty_env = |> add_type ident_option decl_option |> add_type ident_result decl_result |> add_type ident_promise decl_promise + |> add_type ident_tagged_template decl_tagged_template |> add_type ident_array decl_array |> add_type ident_iterable decl_iterable |> add_type ident_async_iterable decl_async_iterable diff --git a/compiler/ml/predef.mli b/compiler/ml/predef.mli index e274ea1cac2..a4112b024c4 100644 --- a/compiler/ml/predef.mli +++ b/compiler/ml/predef.mli @@ -31,6 +31,7 @@ val type_list : type_expr -> type_expr val type_option : type_expr -> type_expr val type_result : type_expr -> type_expr -> type_expr val type_dict : type_expr -> type_expr +val type_tagged_template : type_expr -> type_expr -> type_expr val type_bigint : type_expr val type_extension_constructor : type_expr @@ -53,6 +54,7 @@ val path_dict : Path.t val path_bigint : Path.t val path_extension_constructor : Path.t val path_promise : Path.t +val path_tagged_template : Path.t val path_match_failure : Path.t val path_assert_failure : Path.t diff --git a/packages/@rescript/runtime/Stdlib.res b/packages/@rescript/runtime/Stdlib.res index 2f4ab98f529..fdbda5b4639 100644 --- a/packages/@rescript/runtime/Stdlib.res +++ b/packages/@rescript/runtime/Stdlib.res @@ -29,6 +29,7 @@ module RegExp = Stdlib_RegExp module Result = Stdlib_Result module String = Stdlib_String module Symbol = Stdlib_Symbol +module TaggedTemplate = Stdlib_TaggedTemplate module Type = Stdlib_Type module Iterable = Stdlib_Iterable diff --git a/packages/@rescript/runtime/Stdlib_TaggedTemplate.res b/packages/@rescript/runtime/Stdlib_TaggedTemplate.res new file mode 100644 index 00000000000..21d0562aea4 --- /dev/null +++ b/packages/@rescript/runtime/Stdlib_TaggedTemplate.res @@ -0,0 +1,6 @@ +type t<'param, 'output> = taggedTemplate<'param, 'output> + +// A tagged template tag is invoked by the JS engine as `tag(strings, ...values)`. +// `make` adapts a plain ReScript tag function — which receives the interpolated +// values bundled into a single array — to that variadic call convention. +let make = %raw(`transform => (strings, ...values) => transform(strings, values)`) diff --git a/packages/@rescript/runtime/Stdlib_TaggedTemplate.resi b/packages/@rescript/runtime/Stdlib_TaggedTemplate.resi new file mode 100644 index 00000000000..660226d4f8f --- /dev/null +++ b/packages/@rescript/runtime/Stdlib_TaggedTemplate.resi @@ -0,0 +1,48 @@ +/*** +Utilities for working with JavaScript [tagged templates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates). + +A value of type `TaggedTemplate.t<'param, 'output>` can be used directly with +backtick syntax, and the compiler emits a real JavaScript tagged template +literal at every call site — across module boundaries, when stored in a value, +or when passed as a function argument: + +```rescript +@module("./sql_client.js") +external sql: TaggedTemplate.t<'a, promise> = "sql" + +let users = await sql`SELECT * FROM users WHERE id = ${userId}` +``` +*/ + +/** +Type of a tagged template tag. `'param` is the type accepted in `${...}` +interpolations, `'output` is the type produced by the tag. +*/ +type t<'param, 'output> = taggedTemplate<'param, 'output> + +/** +`make(tag)` lifts a plain ReScript function of the tag-function shape +`(array, array<'param>) => 'output` into a `t<'param, 'output>`, so it +can itself be used with tagged-template backtick syntax — emitting real +JavaScript tagged-template syntax at every call site. + +## Examples + +```rescript +type params = I(int) | S(string) + +let s = TaggedTemplate.make((strings, parameters) => { + Array.reduceWithIndex(parameters, Array.getUnsafe(strings, 0), (acc, param, i) => { + let suffix = Array.getUnsafe(strings, i + 1) + let p = switch param { + | I(i) => Int.toString(i) + | S(s) => s + } + acc ++ p ++ suffix + }) +}) + +let greeting = s`hello ${S("Ada")} you're ${I(36)} years old!` +``` +*/ +let make: ((array, array<'param>) => 'output) => t<'param, 'output> diff --git a/packages/@rescript/runtime/lib/es6/Stdlib.mjs b/packages/@rescript/runtime/lib/es6/Stdlib.mjs index 12c56413de9..72da6171a31 100644 --- a/packages/@rescript/runtime/lib/es6/Stdlib.mjs +++ b/packages/@rescript/runtime/lib/es6/Stdlib.mjs @@ -12,7 +12,7 @@ function assertEqual(a, b) { RE_EXN_ID: "Assert_failure", _1: [ "Stdlib.res", - 160, + 161, 4 ], Error: new Error() @@ -81,6 +81,8 @@ let $$String; let $$Symbol; +let TaggedTemplate; + let Type; let Iterable; @@ -169,6 +171,7 @@ export { Result, $$String, $$Symbol, + TaggedTemplate, Type, Iterable, $$Iterator, diff --git a/packages/@rescript/runtime/lib/es6/Stdlib_TaggedTemplate.mjs b/packages/@rescript/runtime/lib/es6/Stdlib_TaggedTemplate.mjs new file mode 100644 index 00000000000..72ae34e156f --- /dev/null +++ b/packages/@rescript/runtime/lib/es6/Stdlib_TaggedTemplate.mjs @@ -0,0 +1,9 @@ + + + +let make = (transform => (strings, ...values) => transform(strings, values)); + +export { + make, +} +/* No side effect */ diff --git a/packages/@rescript/runtime/lib/js/Stdlib.cjs b/packages/@rescript/runtime/lib/js/Stdlib.cjs index 9426a9ba890..04699b52158 100644 --- a/packages/@rescript/runtime/lib/js/Stdlib.cjs +++ b/packages/@rescript/runtime/lib/js/Stdlib.cjs @@ -12,7 +12,7 @@ function assertEqual(a, b) { RE_EXN_ID: "Assert_failure", _1: [ "Stdlib.res", - 160, + 161, 4 ], Error: new Error() @@ -81,6 +81,8 @@ let $$String; let $$Symbol; +let TaggedTemplate; + let Type; let Iterable; @@ -168,6 +170,7 @@ exports.$$RegExp = $$RegExp; exports.Result = Result; exports.$$String = $$String; exports.$$Symbol = $$Symbol; +exports.TaggedTemplate = TaggedTemplate; exports.Type = Type; exports.Iterable = Iterable; exports.$$Iterator = $$Iterator; diff --git a/packages/@rescript/runtime/lib/js/Stdlib_TaggedTemplate.cjs b/packages/@rescript/runtime/lib/js/Stdlib_TaggedTemplate.cjs new file mode 100644 index 00000000000..5ba30d81595 --- /dev/null +++ b/packages/@rescript/runtime/lib/js/Stdlib_TaggedTemplate.cjs @@ -0,0 +1,7 @@ +'use strict'; + + +let make = (transform => (strings, ...values) => transform(strings, values)); + +exports.make = make; +/* No side effect */ diff --git a/packages/artifacts.json b/packages/artifacts.json index 09009e3f972..19fd3e0e5ec 100644 --- a/packages/artifacts.json +++ b/packages/artifacts.json @@ -191,6 +191,7 @@ "lib/es6/Stdlib_Set.mjs", "lib/es6/Stdlib_String.mjs", "lib/es6/Stdlib_Symbol.mjs", + "lib/es6/Stdlib_TaggedTemplate.mjs", "lib/es6/Stdlib_Type.mjs", "lib/es6/Stdlib_TypedArray.mjs", "lib/es6/Stdlib_Uint16Array.mjs", @@ -372,6 +373,7 @@ "lib/js/Stdlib_Set.cjs", "lib/js/Stdlib_String.cjs", "lib/js/Stdlib_Symbol.cjs", + "lib/js/Stdlib_TaggedTemplate.cjs", "lib/js/Stdlib_Type.cjs", "lib/js/Stdlib_TypedArray.cjs", "lib/js/Stdlib_Uint16Array.cjs", @@ -1260,6 +1262,12 @@ "lib/ocaml/Stdlib_Symbol.cmti", "lib/ocaml/Stdlib_Symbol.res", "lib/ocaml/Stdlib_Symbol.resi", + "lib/ocaml/Stdlib_TaggedTemplate.cmi", + "lib/ocaml/Stdlib_TaggedTemplate.cmj", + "lib/ocaml/Stdlib_TaggedTemplate.cmt", + "lib/ocaml/Stdlib_TaggedTemplate.cmti", + "lib/ocaml/Stdlib_TaggedTemplate.res", + "lib/ocaml/Stdlib_TaggedTemplate.resi", "lib/ocaml/Stdlib_Type.cmi", "lib/ocaml/Stdlib_Type.cmj", "lib/ocaml/Stdlib_Type.cmt", From ecedde0542d1a65270f0b31ad82648693b8b75e8 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Tue, 2 Jun 2026 14:46:47 +0000 Subject: [PATCH 02/21] Type-check tagged-template backtick syntax against taggedTemplate When an application carries the parser's `res.taggedTemplate` attribute, unify the tag's type with a fresh `taggedTemplate<'param, 'output>` and type the desugared `(array, array<'param>)` arguments against it, yielding `'output`. This gives clean inference for unannotated tags (`tag => tag\`...${x}...\``). If the tag's type is not a `taggedTemplate` (e.g. an old `(array, array<'a>) => 'o` tag function), raise a dedicated migration error steering the user to the new binding form or `TaggedTemplate.make`. Part of rescript-lang/rescript#8415. --- compiler/ml/typecore.ml | 56 +++++++++++++++++++++++++++++++++++++--- compiler/ml/typecore.mli | 1 + 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml index 6d0037ca1e8..fee0a3dbe9b 100644 --- a/compiler/ml/typecore.ml +++ b/compiler/ml/typecore.ml @@ -96,6 +96,7 @@ type error = | Type_params_not_supported of Longident.t | Field_access_on_dict_type | Jsx_not_enabled + | Tagged_template_non_tag of type_expr exception Error of Location.t * Env.t * error exception Error_forward of Location.error @@ -2513,10 +2514,45 @@ and type_expect_ ?deprecated_context ~context ?in_function ?(recarg = Rejected) if transformed_jsx then Some JsxComponent else type_clash_context_from_function sexp sfunct in + let is_tagged_template = + Ext_list.exists sexp.pexp_attributes (fun ({txt}, _) -> + txt = "res.taggedTemplate") + in let args, ty_res, fully_applied = - match translate_unified_ops env funct sargs with - | Some (targs, result_type) -> (targs, result_type, true) - | None -> type_application ~context total_app env funct sargs + if is_tagged_template then ( + (* Backtick tagged-template syntax: the tag must be a value of the + builtin [taggedTemplate<'param, 'output>] type. The parser desugars + [tag`a ${x} b`] into [tag([|"a"; " b"|], [|x|])], so the two + arguments are the string parts and the interpolated values. *) + let param_ty = newvar () in + let output_ty = newvar () in + (try + unify env + (instance env funct.exp_type) + (newconstr Predef.path_tagged_template [param_ty; output_ty]) + with Unify _ -> + raise + (Error (funct.exp_loc, env, Tagged_template_non_tag funct.exp_type))); + match sargs with + | [(Nolabel, strings); (Nolabel, values)] -> + let typed_strings = + type_expect ~context:None env strings + (Predef.type_array Predef.type_string) + in + let typed_values = + type_expect ~context:None env values (Predef.type_array param_ty) + in + ( [ + (Asttypes.Nolabel, Some typed_strings); + (Asttypes.Nolabel, Some typed_values); + ], + output_ty, + true ) + | _ -> assert false) + else + match translate_unified_ops env funct sargs with + | Some (targs, result_type) -> (targs, result_type, true) + | None -> type_application ~context total_app env funct sargs in end_def (); unify_var env (newvar ()) funct.exp_type; @@ -4989,6 +5025,20 @@ let report_error env loc ppf error = fprintf ppf "Cannot compile JSX expression because JSX support is not enabled. Add \ \"jsx\" settings to rescript.json to enable JSX support." + | Tagged_template_non_tag typ -> + fprintf ppf + "@[This value is used with tagged template (backtick) syntax, but it \ + has type@ @{%a@}@ which is not a @{taggedTemplate@}.@,\ + @,\ + Tagged template syntax now requires a value of type \ + @{taggedTemplate<'param, 'output>@}:@,\ + @,\ + \ - To bind a JavaScript tag function, annotate the @{external@} \ + with @{taggedTemplate<...>@} instead of using the removed \ + @{@@taggedTemplate@} decorator.@,\ + \ - To use a ReScript function as a tag, lift it with \ + @{TaggedTemplate.make@}.@]" + type_expr typ let report_error env loc ppf err = Printtyp.wrap_printing_env env (fun () -> report_error env loc ppf err) diff --git a/compiler/ml/typecore.mli b/compiler/ml/typecore.mli index 9524ac834a9..cf87fe7361c 100644 --- a/compiler/ml/typecore.mli +++ b/compiler/ml/typecore.mli @@ -129,6 +129,7 @@ type error = | Type_params_not_supported of Longident.t | Field_access_on_dict_type | Jsx_not_enabled + | Tagged_template_non_tag of type_expr exception Error of Location.t * Env.t * error exception Error_forward of Location.error From cf875279ba1d79b2306b72b5b5552ade0d97ebbe Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Tue, 2 Jun 2026 14:46:55 +0000 Subject: [PATCH 03/21] Emit real JS tagged templates for any taggedTemplate value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `Ptagged_template` primitive (`[tag; strings; values]`) carried from the ml lambda layer through to the JS-IR layer. translcore detects a `res.taggedTemplate` application and emits the primitive, translating the tag as a plain value — so the real backtick literal is emitted at every call site regardless of how the tag was obtained (external, let-binding, function parameter, factory result, cross-module). lam_compile_primitive turns the primitive into the existing `Js_exp_make.tagged_template` node, reusing the established JS dump path. Part of rescript-lang/rescript#8415. --- compiler/core/lam_analysis.ml | 4 ++-- compiler/core/lam_compile_primitive.ml | 10 ++++++++++ compiler/core/lam_convert.ml | 1 + compiler/core/lam_primitive.ml | 8 +++++--- compiler/core/lam_primitive.mli | 1 + compiler/core/lam_print.ml | 1 + compiler/ml/lambda.ml | 2 ++ compiler/ml/lambda.mli | 1 + compiler/ml/printlambda.ml | 1 + compiler/ml/translcore.ml | 18 ++++++++++++++++++ 10 files changed, 42 insertions(+), 5 deletions(-) diff --git a/compiler/core/lam_analysis.ml b/compiler/core/lam_analysis.ml index a6b815fdad2..3d1c3d04bad 100644 --- a/compiler/core/lam_analysis.ml +++ b/compiler/core/lam_analysis.ml @@ -91,8 +91,8 @@ let rec no_side_effects (lam : Lam.t) : bool = {code_info = Exp (Js_function _ | Js_literal _) | Stmt Js_stmt_comment} -> true - | Pjs_apply | Pjs_runtime_apply | Pjs_call _ | Pinit_mod | Pupdate_mod - | Pjs_unsafe_downgrade _ | Pdebugger | Pjs_fn_method + | Pjs_apply | Pjs_runtime_apply | Pjs_call _ | Ptagged_template | Pinit_mod + | Pupdate_mod | Pjs_unsafe_downgrade _ | Pdebugger | Pjs_fn_method (* Await promise *) | Pawait (* TODO *) diff --git a/compiler/core/lam_compile_primitive.ml b/compiler/core/lam_compile_primitive.ml index 47aabff81a3..e6a7a86a6e3 100644 --- a/compiler/core/lam_compile_primitive.ml +++ b/compiler/core/lam_compile_primitive.ml @@ -88,6 +88,16 @@ let translate output_prefix loc (cxt : Lam_compile_context.t) match args with | fn :: rest -> E.call ~info:call_info fn rest | _ -> assert false) + | Ptagged_template -> ( + (* [tag; strings_array; values_array] -> tag`...` *) + match args with + | [ + fn; + {expression_desc = Array (strings, _); _}; + {expression_desc = Array (values, _); _}; + ] -> + E.tagged_template fn strings values + | _ -> assert false) | Pnull_to_opt -> ( match args with | [e] -> ( diff --git a/compiler/core/lam_convert.ml b/compiler/core/lam_convert.ml index 3c0c7c058d0..9e0bccdaa93 100644 --- a/compiler/core/lam_convert.ml +++ b/compiler/core/lam_convert.ml @@ -207,6 +207,7 @@ let lam_prim ~primitive:(p : Lambda.primitive) ~args loc : Lam.t = | Pfield (id, info) -> prim ~primitive:(Pfield (id, info)) ~args loc | Psetfield (id, info) -> prim ~primitive:(Psetfield (id, info)) ~args loc | Pduprecord -> prim ~primitive:Pduprecord ~args loc + | Ptagged_template -> prim ~primitive:Ptagged_template ~args loc | Praise _ -> prim ~primitive:Praise ~args loc | Pobjcomp x -> prim ~primitive:(Pobjcomp x) ~args loc | Pobjorder -> prim ~primitive:Pobjorder ~args loc diff --git a/compiler/core/lam_primitive.ml b/compiler/core/lam_primitive.ml index 2135293c850..4e3a53d3c07 100644 --- a/compiler/core/lam_primitive.ml +++ b/compiler/core/lam_primitive.ml @@ -40,6 +40,8 @@ type t = | Psetfield of int * Lam_compat.set_field_dbg_info (* could have field info at least for record *) | Pduprecord + (* Tagged template literal: [tag; strings_array; values_array] *) + | Ptagged_template (* External call *) | Pjs_call of { prim_name: string; @@ -226,9 +228,9 @@ let eq_primitive_approx (lhs : t) (rhs : t) = | Pnull_to_opt | Pnull_undefined_to_opt | Pis_null | Pis_not_none | Psome | Psome_not_nest | Pis_undefined | Pis_null_undefined | Pimport | Ptypeof | Pfn_arity | Pis_poly_var_block | Pdebugger | Pinit_mod | Pupdate_mod - | Pduprecord | Pmakearray | Parraylength | Parrayrefu | Parraysetu - | Parrayrefs | Parraysets | Pjs_fn_make_unit | Pjs_fn_method | Phash - | Phash_mixstring | Phash_mixint | Phash_finalmix -> + | Pduprecord | Ptagged_template | Pmakearray | Parraylength | Parrayrefu + | Parraysetu | Parrayrefs | Parraysets | Pjs_fn_make_unit | Pjs_fn_method + | Phash | Phash_mixstring | Phash_mixint | Phash_finalmix -> rhs = lhs | Pcreate_extension a -> ( match rhs with diff --git a/compiler/core/lam_primitive.mli b/compiler/core/lam_primitive.mli index 879f03a4120..8c0d26a89e1 100644 --- a/compiler/core/lam_primitive.mli +++ b/compiler/core/lam_primitive.mli @@ -36,6 +36,7 @@ type t = | Pfield of int * Lambda.field_dbg_info | Psetfield of int * Lambda.set_field_dbg_info | Pduprecord + | Ptagged_template | Pjs_call of { (* Location.t * [loc] is passed down *) prim_name: string; diff --git a/compiler/core/lam_print.ml b/compiler/core/lam_print.ml index 42b9a294b30..98127a471f8 100644 --- a/compiler/core/lam_print.ml +++ b/compiler/core/lam_print.ml @@ -51,6 +51,7 @@ let primitive ppf (prim : Lam_primitive.t) = | Pupdate_mod -> fprintf ppf "update_mod!" | Pjs_apply -> fprintf ppf "#apply" | Pjs_runtime_apply -> fprintf ppf "#runtime_apply" + | Ptagged_template -> fprintf ppf "#tagged_template" | Pjs_unsafe_downgrade {name; setter} -> if setter then fprintf ppf "##%s#=" name else fprintf ppf "##%s" name | Pfn_arity -> fprintf ppf "fn.length" diff --git a/compiler/ml/lambda.ml b/compiler/ml/lambda.ml index 33078a59fde..e078a2a28f8 100644 --- a/compiler/ml/lambda.ml +++ b/compiler/ml/lambda.ml @@ -306,6 +306,8 @@ type primitive = | Pjs_fn_make of int | Pjs_fn_make_unit | Pjs_fn_method + (* Tagged template literal: [tag; strings_array; values_array] *) + | Ptagged_template and comparison = Ceq | Cneq | Clt | Cgt | Cle | Cge diff --git a/compiler/ml/lambda.mli b/compiler/ml/lambda.mli index 0440d20c7f3..99f399aa0ac 100644 --- a/compiler/ml/lambda.mli +++ b/compiler/ml/lambda.mli @@ -275,6 +275,7 @@ type primitive = | Pjs_fn_make of int | Pjs_fn_make_unit | Pjs_fn_method + | Ptagged_template and comparison = Ceq | Cneq | Clt | Cgt | Cle | Cge diff --git a/compiler/ml/printlambda.ml b/compiler/ml/printlambda.ml index 27fa0452461..47ca006b618 100644 --- a/compiler/ml/printlambda.ml +++ b/compiler/ml/printlambda.ml @@ -263,6 +263,7 @@ let primitive ppf = function | Pjs_fn_make arity -> fprintf ppf "#fn_mk(%d)" arity | Pjs_fn_make_unit -> fprintf ppf "#fn_mk_unit" | Pjs_fn_method -> fprintf ppf "#fn_method" + | Ptagged_template -> fprintf ppf "#tagged_template" let function_attribute ppf {inline; is_a_functor; return_unit} = if is_a_functor then fprintf ppf "is_a_functor@ "; diff --git a/compiler/ml/translcore.ml b/compiler/ml/translcore.ml index ecf3a73528d..69fbcab4729 100644 --- a/compiler/ml/translcore.ml +++ b/compiler/ml/translcore.ml @@ -718,6 +718,24 @@ and transl_exp0 (e : Typedtree.expression) : Lambda.lambda = [lambda], loc ) | None -> lambda) + | Texp_apply {funct; args = oargs} + when List.exists + (fun (attr, _) -> attr.txt = "res.taggedTemplate") + e.exp_attributes -> + (* Backtick tagged-template syntax on a value of the builtin + [taggedTemplate<'param, 'output>] type. Typecore has already checked the + tag's type, so here we just emit a real JS tagged-template literal, + regardless of how the tag value was obtained (external, let-binding, + function parameter, factory result, cross-module). *) + let strings, values = + match oargs with + | [(_, Some strings); (_, Some values)] -> (strings, values) + | _ -> assert false + in + Lprim + ( Ptagged_template, + [transl_exp funct; transl_exp strings; transl_exp values], + e.exp_loc ) | Texp_apply { funct = From 53e3cce31c2a28eaed4872e812736529b936b6c0 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Tue, 2 Jun 2026 14:47:01 +0000 Subject: [PATCH 04/21] Remove @taggedTemplate decorator and its dead FFI path Tagged-templateness now lives on the type, so the `@taggedTemplate` external decorator is obsolete. Using it is now a compile error that steers users to bind the external with the `taggedTemplate<...>` type instead. With the decorator gone, the `tagged_template` flag on the `Js_call` FFI spec is always false, so remove the field and the now-unreachable tagged-template branch in lam_compile_external_call. Part of rescript-lang/rescript#8415. --- compiler/core/lam_compile_external_call.ml | 27 +----------------- compiler/frontend/ast_external_process.ml | 32 ++++++++-------------- compiler/frontend/external_ffi_types.ml | 5 +--- compiler/frontend/external_ffi_types.mli | 1 - 4 files changed, 13 insertions(+), 52 deletions(-) diff --git a/compiler/core/lam_compile_external_call.ml b/compiler/core/lam_compile_external_call.ml index bf7a2456739..bc12f870dfc 100644 --- a/compiler/core/lam_compile_external_call.ml +++ b/compiler/core/lam_compile_external_call.ml @@ -271,32 +271,7 @@ let translate_ffi ?(transformed_jsx = false) (cxt : Lam_compile_context.t) arg_types (ffi : External_ffi_types.external_spec) (args : J.expression list) ~dynamic_import = match ffi with - | Js_call - {external_module_name; name; splice : _; scopes; tagged_template = true} - -> ( - let fn = - translate_scoped_module_val external_module_name name scopes - ~dynamic_import - in - match args with - | [ - {expression_desc = Array (strings, _); _}; - {expression_desc = Array (values, _); _}; - ] -> - E.tagged_template fn strings values - | _ -> - let args, eff, dynamic = assemble_args_has_splice arg_types args in - let args = if dynamic then E.variadic_args args else args in - add_eff eff - (E.call ~info:(Js_call_info.na_full_call transformed_jsx) fn args)) - | Js_call - { - external_module_name = module_name; - name = fn; - splice; - scopes; - tagged_template = false; - } -> + | Js_call {external_module_name = module_name; name = fn; splice; scopes} -> let fn = translate_scoped_module_val module_name fn scopes ~dynamic_import in diff --git a/compiler/frontend/ast_external_process.ml b/compiler/frontend/ast_external_process.ml index 2c27e686868..ab9831756dd 100644 --- a/compiler/frontend/ast_external_process.ml +++ b/compiler/frontend/ast_external_process.ml @@ -166,7 +166,6 @@ type external_desc = { get_name: bundle_source option; mk_obj: bool; return_wrapper: External_ffi_types.return_wrapper; - tagged_template: bool; } let init_st = @@ -185,7 +184,6 @@ let init_st = get_name = None; mk_obj = false; return_wrapper = Return_unset; - tagged_template = false; } let return_wrapper loc (txt : string) : External_ffi_types.return_wrapper = @@ -353,7 +351,13 @@ let parse_external_attributes (no_arguments : bool) (prim_name_check : string) between unset/set *) | scopes -> {st with scopes}) - | "taggedTemplate" -> {st with splice = true; tagged_template = true} + | "taggedTemplate" -> + Location.raise_errorf ~loc + "The @@taggedTemplate decorator has been removed. Bind the \ + external with the builtin taggedTemplate<'param, 'output> type \ + instead, e.g. `@@module(\"x\") external sql: taggedTemplate<'a, \ + string> = \"sql\"`. The tag can then be used with backtick \ + syntax across module boundaries and as a first-class value." | "variadic" -> {st with splice = true} | "send" -> {st with val_send = Some (name_from_payload_or_prim ~loc payload)} @@ -423,7 +427,6 @@ let process_obj (loc : Location.t) (st : external_desc) (prim_name : string) get_name = None; get_index = false; return_wrapper = Return_unset; - tagged_template = _; set_index = false; mk_obj = _; scopes = @@ -614,7 +617,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) get_name = None; return_wrapper = _; mk_obj = _; - tagged_template = _; } -> if arg_type_specs_length = 3 then Js_set_index {js_set_index_scopes = scopes} @@ -639,7 +641,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) set_index = false; mk_obj = _; return_wrapper = _; - tagged_template = _; } -> if arg_type_specs_length = 2 then Js_get_index {js_get_index_scopes = scopes} @@ -666,7 +667,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) set_index = false; return_wrapper = _; mk_obj = _; - tagged_template = _; } -> ( match (arg_types_ty, new_name, val_name) with | [], None, _ -> Js_module_as_var external_module_name @@ -708,7 +708,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) mk_obj = _; (* mk_obj is always false *) return_wrapper = _; - tagged_template; } -> let name = prim_name_or_pval_prim.name in if arg_type_specs_length = 0 then @@ -716,12 +715,10 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) {[ external ff : int -> int [@bs] = "" [@@module "xx"] ]} - FIXME: splice is not supported here + FIXME: splice is not supported here *) Js_var {name; external_module_name = None; scopes} - else - Js_call - {splice; name; external_module_name = None; scopes; tagged_template} + else Js_call {splice; name; external_module_name = None; scopes} | { call_name = Some {name; source = _}; splice; @@ -737,7 +734,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) get_name = None; mk_obj = _; return_wrapper = _; - tagged_template; } -> if arg_type_specs_length = 0 then (* @@ -747,7 +743,7 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) *) Js_var {name; external_module_name; scopes} (*FIXME: splice is not supported here *) - else Js_call {splice; name; external_module_name; scopes; tagged_template} + else Js_call {splice; name; external_module_name; scopes} | {call_name = Some _} -> Bs_syntaxerr.err loc (Conflict_ffi_attribute "Attribute found that conflicts with @val") @@ -766,7 +762,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) return_wrapper = _; splice = false; scopes; - tagged_template = _; } -> (* if no_arguments --> @@ -793,7 +788,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) get_name = None; mk_obj = _; return_wrapper = _; - tagged_template; } -> let name = prim_name_or_pval_prim.name in if arg_type_specs_length = 0 then @@ -803,7 +797,7 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) ]} *) Js_var {name; external_module_name; scopes} - else Js_call {splice; name; external_module_name; scopes; tagged_template} + else Js_call {splice; name; external_module_name; scopes} | { val_send = Some {name; source = _}; splice; @@ -819,7 +813,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) external_module_name = None; mk_obj = _; return_wrapper = _; - tagged_template = _; } -> ( (* PR #2162 - since when we assemble arguments the first argument in [@@send] is ignored @@ -851,7 +844,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) scopes; mk_obj = _; return_wrapper = _; - tagged_template = _; } -> Js_new {name; external_module_name; splice; scopes} | {new_name = Some _} -> @@ -872,7 +864,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) mk_obj = _; return_wrapper = _; scopes; - tagged_template = _; } -> if arg_type_specs_length = 2 then Js_set {js_set_scopes = scopes; js_set_name = name} @@ -896,7 +887,6 @@ let external_desc_of_non_obj (loc : Location.t) (st : external_desc) mk_obj = _; return_wrapper = _; scopes; - tagged_template = _; } -> if arg_type_specs_length = 1 then (* Check if the first argument is unit, which is invalid for @get *) diff --git a/compiler/frontend/external_ffi_types.ml b/compiler/frontend/external_ffi_types.ml index ef6c1c351fe..3633bb35ad1 100644 --- a/compiler/frontend/external_ffi_types.ml +++ b/compiler/frontend/external_ffi_types.ml @@ -59,7 +59,6 @@ type external_spec = external_module_name: external_module_name option; splice: bool; scopes: string list; - tagged_template: bool; } | Js_send of {name: string; splice: bool; js_send_scopes: string list} (* we know it is a js send, but what will happen if you pass an ocaml objct *) @@ -194,9 +193,7 @@ let check_ffi ?loc ffi : bool = upgrade (is_package_relative_path external_module_name.bundle); check_external_module_name external_module_name | Js_new {external_module_name; name; splice = _; scopes = _} - | Js_call - {external_module_name; name; splice = _; scopes = _; tagged_template = _} - -> + | Js_call {external_module_name; name; splice = _; scopes = _} -> Ext_option.iter external_module_name (fun external_module_name -> upgrade (is_package_relative_path external_module_name.bundle)); Ext_option.iter external_module_name (fun name -> diff --git a/compiler/frontend/external_ffi_types.mli b/compiler/frontend/external_ffi_types.mli index 4a0a87c59f8..a40e85ad50d 100644 --- a/compiler/frontend/external_ffi_types.mli +++ b/compiler/frontend/external_ffi_types.mli @@ -56,7 +56,6 @@ type external_spec = external_module_name: external_module_name option; splice: bool; scopes: string list; - tagged_template: bool; } | Js_send of {name: string; splice: bool; js_send_scopes: string list} (* we know it is a js send, but what will happen if you pass an ocaml objct *) From 65464aa001283145edf591009342aca1568ae5fc Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Tue, 2 Jun 2026 14:56:03 +0000 Subject: [PATCH 05/21] Support taggedTemplate in editor analysis and gentype Teach the completion engine that applying a value of type `taggedTemplate<'param, 'output>` (via backtick syntax) yields its `'output` type, restoring dot/pipe completions on a tagged-template result. Update the analysis fixture to the new binding form. Map the `taggedTemplate` builtin to an opaque type in gentype (it is a variadic JS function), so genType'd values don't emit a dangling TS reference. Part of rescript-lang/rescript#8415. --- analysis/src/completion_back_end.ml | 14 +++++++++++++- compiler/gentype/translate_type_expr_from_types.ml | 4 ++++ .../tests/src/CompletionTaggedTemplate.res | 12 ++++++------ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/analysis/src/completion_back_end.ml b/analysis/src/completion_back_end.ml index 66524705675..caae07908a2 100644 --- a/analysis/src/completion_back_end.ml +++ b/analysis/src/completion_back_end.ml @@ -1123,7 +1123,19 @@ and get_completions_for_context_path ~debug ~full ~opens ~raw_opens ~pos ~env let args = process_apply args labels in let ret_type = reconstruct_function_type args t_ret in [Completion.create "dummy" ~env ~kind:(Completion.Value ret_type)] - | _ -> []) + | _ -> ( + (* A tagged template tag has type taggedTemplate<'param, 'output>; + applying it (via backtick syntax) yields the 'output type. *) + let rec unwrap (t : Types.type_expr) = + match t.desc with + | Tlink t1 | Tsubst t1 | Tpoly (t1, []) -> unwrap t1 + | _ -> t + in + match (unwrap typ).desc with + | Tconstr (path, [_param; output_type], _) + when Path.name path = "taggedTemplate" -> + [Completion.create "dummy" ~env ~kind:(Completion.Value output_type)] + | _ -> [])) | _ -> []) | CPField {context_path = CPId {path; completion_context = Module}; field_name} -> diff --git a/compiler/gentype/translate_type_expr_from_types.ml b/compiler/gentype/translate_type_expr_from_types.ml index 6576fdc8ccb..fad9cf8e7fd 100644 --- a/compiler/gentype/translate_type_expr_from_types.ml +++ b/compiler/gentype/translate_type_expr_from_types.ml @@ -436,6 +436,10 @@ let translate_constr ~config ~params_translation ~(path : Path.t) ~type_env = [param_translation] ) -> {param_translation with type_ = Dict param_translation.type_} | ["Stdlib"; "JSON"; "t"], [] -> {dependencies = []; type_ = unknown} + | (["taggedTemplate"] | ["Stdlib"; "TaggedTemplate"; "t"]), [_param; _output] + -> + (* A tagged-template tag is a variadic JS function; represent it opaquely. *) + {dependencies = []; type_ = unknown} | _ -> default_case () type process_variant = { diff --git a/tests/analysis_tests/tests/src/CompletionTaggedTemplate.res b/tests/analysis_tests/tests/src/CompletionTaggedTemplate.res index df033cd7c80..7186c53bc3f 100644 --- a/tests/analysis_tests/tests/src/CompletionTaggedTemplate.res +++ b/tests/analysis_tests/tests/src/CompletionTaggedTemplate.res @@ -1,13 +1,13 @@ module M = { - type t = promise + type t = promise - let a = (_t:t) => 4 - let b = (_:t) => "c" - let xyz = (_:t, p:int) => p + 1 + let a = (_t: t) => 4 + let b = (_: t) => "c" + let xyz = (_: t, p: int) => p + 1 } -@module("meh") @taggedTemplate -external meh: (array, array) => M.t = "default" +@module("meh") +external meh: taggedTemplate = "default" let w = meh`` From 7065d276a03eab060e59857e672011640fc2467a Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Tue, 2 Jun 2026 14:56:10 +0000 Subject: [PATCH 06/21] Add tests and docs for first-class taggedTemplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the tagged-template runtime test around the new model: an external bound with the `taggedTemplate` type, a runtime-constructed tag via a factory, a tag passed as a function argument, and a ReScript tag lifted with `TaggedTemplate.make` — asserting real tagged-template syntax is emitted at every call site. Add super_errors fixtures for the two new errors (removed `@taggedTemplate` decorator, and backtick syntax on a non-tag value), catalog the new `Tagged_template_non_tag` variant in ERROR_VARIANTS.md, and add CHANGELOG entries. Part of rescript-lang/rescript#8415. --- CHANGELOG.md | 3 + tests/ERROR_VARIANTS.md | 1 + ...ed_template_decorator_removed.res.expected | 9 +++ .../tagged_template_non_tag.res.expected | 17 ++++ .../tagged_template_decorator_removed.res | 4 + .../fixtures/tagged_template_non_tag.res | 10 +++ tests/tests/src/tagged_template_lib.js | 15 +++- tests/tests/src/tagged_template_test.mjs | 78 ++++++++++++------- tests/tests/src/tagged_template_test.res | 63 ++++++++++----- 9 files changed, 153 insertions(+), 47 deletions(-) create mode 100644 tests/build_tests/super_errors/expected/tagged_template_decorator_removed.res.expected create mode 100644 tests/build_tests/super_errors/expected/tagged_template_non_tag.res.expected create mode 100644 tests/build_tests/super_errors/fixtures/tagged_template_decorator_removed.res create mode 100644 tests/build_tests/super_errors/fixtures/tagged_template_non_tag.res diff --git a/CHANGELOG.md b/CHANGELOG.md index 38de8c09822..e4b40d8f973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,14 @@ - Make `Jsx.component` abstract. https://github.com/rescript-lang/rescript/pull/8390 - Drop Node.js version 20.x support, as it is reaching EOL. https://github.com/rescript-lang/rescript/pull/8401 +- Remove the `@taggedTemplate` decorator in favor of the new first-class `taggedTemplate<'param, 'output>` builtin type. Using the decorator, or backtick tagged-template syntax on a value that is not a `taggedTemplate`, is now a compile error pointing to the new binding form. https://github.com/rescript-lang/rescript/issues/8415 #### :eyeglasses: Spec Compliance #### :rocket: New Feature +- Add a first-class `taggedTemplate<'param, 'output>` builtin type and the `TaggedTemplate` stdlib module (`TaggedTemplate.make`). Tagged-template tags are now tracked through the type system, so they emit real JS tagged-template syntax across module boundaries, when passed as first-class values, and when constructed at runtime by a factory (e.g. `postgres`). https://github.com/rescript-lang/rescript/issues/8415 + #### :bug: Bug fix - Fix directive `@warning("-102")` not working. https://github.com/rescript-lang/rescript/pull/8322 diff --git a/tests/ERROR_VARIANTS.md b/tests/ERROR_VARIANTS.md index b37e62fdcbe..5a4b068e55b 100644 --- a/tests/ERROR_VARIANTS.md +++ b/tests/ERROR_VARIANTS.md @@ -244,6 +244,7 @@ Source: [typecore.ml:27](../compiler/ml/typecore.ml). | `Type_params_not_supported` | ✓ | `variant_spread_pattern_type_params.res` | Pattern-level variant spread (`| ...a as v`) where `a` has type params; typedecl path covered by `variant_spread_type_parameters.res`. | | `Field_access_on_dict_type` | ✓ | `field_access_on_dict_type.res` | | | `Jsx_not_enabled` | ☐ (needs harness flag) | — | typecore.ml:218/3470. Fires when JSX is used without `-bs-jsx N`. The `super_errors` runner hard-codes `-bs-jsx 4` in `bscFlags`; adding a per-fixture opt-out (e.g. a `.opts` sidecar) would expose this. Until then, it's reachable in real code but blocked at the harness level. | +| `Tagged_template_non_tag` | ✓ | `tagged_template_non_tag.res` | Backtick tagged-template syntax used on a value whose type is not `taggedTemplate<'param, 'output>`. | --- diff --git a/tests/build_tests/super_errors/expected/tagged_template_decorator_removed.res.expected b/tests/build_tests/super_errors/expected/tagged_template_decorator_removed.res.expected new file mode 100644 index 00000000000..278b6dff772 --- /dev/null +++ b/tests/build_tests/super_errors/expected/tagged_template_decorator_removed.res.expected @@ -0,0 +1,9 @@ + + We've found a bug for you! + /.../fixtures/tagged_template_decorator_removed.res:1:1-15 + + 1 │ @taggedTemplate + 2 │ external sql: (array, array) => string = "sql" + 3 │ + + The @taggedTemplate decorator has been removed. Bind the external with the builtin taggedTemplate<'param, 'output> type instead, e.g. `@module("x") external sql: taggedTemplate<'a, string> = "sql"`. The tag can then be used with backtick syntax across module boundaries and as a first-class value. \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/tagged_template_non_tag.res.expected b/tests/build_tests/super_errors/expected/tagged_template_non_tag.res.expected new file mode 100644 index 00000000000..af7da425754 --- /dev/null +++ b/tests/build_tests/super_errors/expected/tagged_template_non_tag.res.expected @@ -0,0 +1,17 @@ + + We've found a bug for you! + /.../fixtures/tagged_template_non_tag.res:10:11-13 + + 8 │ } + 9 │ + 10 │ let res = foo`| 5 × 10 = ${5} |` + 11 │ + + This value is used with tagged template (backtick) syntax, but it has type + (array, array) => string + which is not a taggedTemplate. + + Tagged template syntax now requires a value of type taggedTemplate<'param, 'output>: + + - To bind a JavaScript tag function, annotate the external with taggedTemplate<...> instead of using the removed @taggedTemplate decorator. + - To use a ReScript function as a tag, lift it with TaggedTemplate.make. \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/tagged_template_decorator_removed.res b/tests/build_tests/super_errors/fixtures/tagged_template_decorator_removed.res new file mode 100644 index 00000000000..6cc10ffe0db --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/tagged_template_decorator_removed.res @@ -0,0 +1,4 @@ +@taggedTemplate +external sql: (array, array) => string = "sql" + +let _ = sql diff --git a/tests/build_tests/super_errors/fixtures/tagged_template_non_tag.res b/tests/build_tests/super_errors/fixtures/tagged_template_non_tag.res new file mode 100644 index 00000000000..0e3098530ce --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/tagged_template_non_tag.res @@ -0,0 +1,10 @@ +// Using backtick tagged-template syntax on a value that is not a +// `taggedTemplate` (here a plain function with the old tag-function shape) +// is now an error that points to the migration path. +let foo = (strings: array, values: array) => { + ignore(strings) + ignore(values) + "result" +} + +let res = foo`| 5 × 10 = ${5} |` diff --git a/tests/tests/src/tagged_template_lib.js b/tests/tests/src/tagged_template_lib.js index beaacb5b0a3..6d8bd67f009 100644 --- a/tests/tests/src/tagged_template_lib.js +++ b/tests/tests/src/tagged_template_lib.js @@ -7,6 +7,17 @@ export const sql = (strings, ...values) => { return result; }; -export const length = (strings, ...values) => - strings.reduce((acc, curr) => acc + curr.length, 0) + +export const length = (strings, ...values) => + strings.reduce((acc, curr) => acc + curr.length, 0) + values.reduce((acc, curr) => acc + curr, 0); + +// Factory that returns a tag function, mirroring libraries like `postgres` +// whose default export is a factory and the value it returns *is* the tag. +export const makeSql = (prefix) => (strings, ...values) => { + let result = prefix; + for (let i = 0; i < values.length; i++) { + result += strings[i] + "'" + values[i] + "'"; + } + result += strings[values.length]; + return result; +}; diff --git a/tests/tests/src/tagged_template_test.mjs b/tests/tests/src/tagged_template_test.mjs index 65155ab03de..5ccb1759614 100644 --- a/tests/tests/src/tagged_template_test.mjs +++ b/tests/tests/src/tagged_template_test.mjs @@ -2,11 +2,11 @@ import * as Mocha from "mocha"; import * as Test_utils from "./test_utils.mjs"; +import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.mjs"; +import * as Stdlib_TaggedTemplate from "@rescript/runtime/lib/es6/Stdlib_TaggedTemplate.mjs"; import * as Tagged_template_libJs from "./tagged_template_lib.js"; -function sql(prim0, prim1) { - return Tagged_template_libJs.sql(prim0, ...prim1); -} +let sql = Tagged_template_libJs.sql; let Pg = { sql: sql @@ -16,35 +16,54 @@ let table = "users"; let id = "5"; -let queryWithModule = Tagged_template_libJs.sql`SELECT * FROM ${table} WHERE id = ${id}`; +let queryWithModule = sql`SELECT * FROM ${table} WHERE id = ${id}`; -let query = Tagged_template_libJs.sql` +let query = sql` " SELECT * FROM ${table} WHERE id = ${id}`; -let length = Tagged_template_libJs.length`hello ${10} what's the total length? Is it ${3}?`; +let length = Tagged_template_libJs.length; + +let length$1 = length`hello ${10} what's the total length? Is it ${3}?`; + +function makeSql(prim) { + return Tagged_template_libJs.makeSql(prim); +} + +let prefixedSql = Tagged_template_libJs.makeSql("PREFIX "); + +let factoryQuery = prefixedSql`SELECT * FROM ${table}`; -function foo(strings, values) { - let res = ""; - let valueCount = values.length; - for (let i = 0; i < valueCount; ++i) { - res = res + strings[i] + (values[i] * 10 | 0).toString(); - } - return res + strings[valueCount]; +function runQuery(tag) { + return tag`SELECT id = ${id}`; } -let res = foo([ - `| 5 × 10 = `, - ` |` -], [5]); +let paramQuery = runQuery(sql); + +let s = Stdlib_TaggedTemplate.make((strings, parameters) => Stdlib_Array.reduceWithIndex(parameters, strings[0], (acc, param, i) => { + let suffix = strings[i + 1 | 0]; + let p; + p = param.TAG === "I" ? param._0.toString() : param._0; + return acc + p + suffix; +})); + +let greeting = s`hello ${{ + TAG: "S", + _0: "Ada" +}} you're ${{ + TAG: "I", + _0: 36 +}} years old!`; Mocha.describe("tagged templates", () => { - Mocha.test("with externals, it should return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 41, characters 6-13", query, ` + Mocha.test("with externals, it should return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 60, characters 6-13", query, ` " SELECT * FROM 'users' WHERE id = '5'`)); - Mocha.test("with module scoped externals, it should also return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 50, characters 13-20", queryWithModule, "SELECT * FROM 'users' WHERE id = '5'")); - Mocha.test("with externals, it should return the result of the function", () => Test_utils.eq("File \"tagged_template_test.res\", line 53, characters 79-86", length, 52)); - Mocha.test("with rescript function, it should return a string with the correct encoding and interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 57, characters 13-20", res, "| 5 × 10 = 50 |")); - Mocha.test("a template literal tagged with json should generate a regular string interpolation for now", () => Test_utils.eq("File \"tagged_template_test.res\", line 62, characters 13-20", "some random " + "string", "some random string")); - Mocha.test("a regular string interpolation should continue working", () => Test_utils.eq("File \"tagged_template_test.res\", line 66, characters 7-14", `some random ` + "string" + ` interpolation`, "some random string interpolation")); + Mocha.test("with module scoped externals, it should also return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 69, characters 13-20", queryWithModule, "SELECT * FROM 'users' WHERE id = '5'")); + Mocha.test("with externals, it should return the result of the function", () => Test_utils.eq("File \"tagged_template_test.res\", line 72, characters 79-86", length$1, 52)); + Mocha.test("with a runtime-constructed tag (factory), it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 75, characters 7-14", factoryQuery, "PREFIX SELECT * FROM 'users'")); + Mocha.test("with a tag passed as a function argument, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 79, characters 7-14", paramQuery, "SELECT id = '5'")); + Mocha.test("with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", () => Test_utils.eq("File \"tagged_template_test.res\", line 84, characters 13-20", greeting, "hello Ada you're 36 years old!")); + Mocha.test("a template literal tagged with json should generate a regular string interpolation for now", () => Test_utils.eq("File \"tagged_template_test.res\", line 89, characters 13-20", "some random " + "string", "some random string")); + Mocha.test("a regular string interpolation should continue working", () => Test_utils.eq("File \"tagged_template_test.res\", line 93, characters 7-14", `some random ` + "string" + ` interpolation`, "some random string interpolation")); }); let extraLength = 10; @@ -56,8 +75,13 @@ export { queryWithModule, query, extraLength, - length, - foo, - res, + length$1 as length, + makeSql, + prefixedSql, + factoryQuery, + runQuery, + paramQuery, + s, + greeting, } -/* queryWithModule Not a pure module */ +/* sql Not a pure module */ diff --git a/tests/tests/src/tagged_template_test.res b/tests/tests/src/tagged_template_test.res index f8c579be689..6ced3e80e3d 100644 --- a/tests/tests/src/tagged_template_test.res +++ b/tests/tests/src/tagged_template_test.res @@ -1,9 +1,11 @@ open Mocha open Test_utils +// A tag bound with the builtin `taggedTemplate` type. No decorator needed: the +// type is what makes the compiler emit real JS tagged-template syntax. module Pg = { - @module("./tagged_template_lib.js") @taggedTemplate - external sql: (array, array) => string = "sql" + @module("./tagged_template_lib.js") + external sql: taggedTemplate = "sql" } let table = "users" @@ -11,29 +13,46 @@ let id = "5" let queryWithModule = Pg.sql`SELECT * FROM ${table} WHERE id = ${id}` +// The tag still emits backticks when used through `open` (i.e. once it has +// crossed the module boundary as a value of the `taggedTemplate` type). open Pg let query = sql` " SELECT * FROM ${table} WHERE id = ${id}` -@module("./tagged_template_lib.js") @taggedTemplate -external length: (array, array) => int = "length" +@module("./tagged_template_lib.js") +external length: taggedTemplate = "length" let extraLength = 10 let length = length`hello ${extraLength} what's the total length? Is it ${3}?` -let foo = (strings, values) => { - let res = ref("") - let valueCount = Belt.Array.length(values) - for i in 0 to valueCount - 1 { - res := - res.contents ++ - strings->Array.getUnsafe(i) ++ - Js.Int.toString(values->Array.getUnsafe(i) * 10) - } - res.contents ++ strings->Array.getUnsafe(valueCount) -} +// Problem 1: a tag constructed at runtime by a factory (postgres-style). +@module("./tagged_template_lib.js") +external makeSql: string => taggedTemplate = "makeSql" + +let prefixedSql = makeSql("PREFIX ") +let factoryQuery = prefixedSql`SELECT * FROM ${table}` + +// Problem 2c: the tag flows through a function parameter and is still emitted +// as a real tagged template at the call site inside the function. +let runQuery = (tag: taggedTemplate) => tag`SELECT id = ${id}` +let paramQuery = runQuery(Pg.sql) + +// Problem 3: a ReScript-authored tag, lifted into the type with +// `TaggedTemplate.make`, is usable as a tag too. +type params = I(int) | S(string) + +let s = TaggedTemplate.make((strings, parameters) => { + Array.reduceWithIndex(parameters, Array.getUnsafe(strings, 0), (acc, param, i) => { + let suffix = Array.getUnsafe(strings, i + 1) + let p = switch param { + | I(i) => Int.toString(i) + | S(s) => s + } + acc ++ p ++ suffix + }) +}) -let res = foo`| 5 × 10 = ${5} |` +let greeting = s`hello ${S("Ada")} you're ${I(36)} years old!` describe("tagged templates", () => { test("with externals, it should return a string with the correct interpolations", () => @@ -52,9 +71,17 @@ describe("tagged templates", () => { test("with externals, it should return the result of the function", () => eq(__LOC__, length, 52)) + test("with a runtime-constructed tag (factory), it should emit tagged-template syntax", () => + eq(__LOC__, factoryQuery, "PREFIX SELECT * FROM 'users'") + ) + + test("with a tag passed as a function argument, it should emit tagged-template syntax", () => + eq(__LOC__, paramQuery, "SELECT id = '5'") + ) + test( - "with rescript function, it should return a string with the correct encoding and interpolations", - () => eq(__LOC__, res, "| 5 × 10 = 50 |"), + "with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", + () => eq(__LOC__, greeting, "hello Ada you're 36 years old!"), ) test( From 1443b5455ba92c4314005ae288e36bdf334a099b Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 10:27:21 +0000 Subject: [PATCH 07/21] Expand tagged-template runtime test coverage Add runtime assertions that pin down the real behaviour of the feature: - A `rawTag` binding proves the compiler emits a genuine tagged template: the tag receives a frozen `TemplateStringsArray` with `.raw`, not a plain/variadic function call. - A cross-module case: a `taggedTemplate` value defined in `Tagged_template_binding` and consumed here with backtick syntax, so the consumer sees only the type yet still emits a real tagged template. - A compile-only `tagged_template_global_import` fixture pins the generated JS for a bare-package import (`@module("postgres")`) returning a `taggedTemplate`, vs. the existing relative `./file.js` bindings. Part of rescript-lang/rescript#8415. --- tests/tests/src/tagged_template_binding.mjs | 15 +++++++ tests/tests/src/tagged_template_binding.res | 9 ++++ .../src/tagged_template_global_import.mjs | 17 +++++++ .../src/tagged_template_global_import.res | 19 ++++++++ tests/tests/src/tagged_template_lib.js | 11 +++++ tests/tests/src/tagged_template_test.mjs | 44 +++++++++++++++---- tests/tests/src/tagged_template_test.res | 31 +++++++++++++ 7 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 tests/tests/src/tagged_template_binding.mjs create mode 100644 tests/tests/src/tagged_template_binding.res create mode 100644 tests/tests/src/tagged_template_global_import.mjs create mode 100644 tests/tests/src/tagged_template_global_import.res diff --git a/tests/tests/src/tagged_template_binding.mjs b/tests/tests/src/tagged_template_binding.mjs new file mode 100644 index 00000000000..4b0a5107f13 --- /dev/null +++ b/tests/tests/src/tagged_template_binding.mjs @@ -0,0 +1,15 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Tagged_template_libJs from "./tagged_template_lib.js"; + +function makeSql(prim) { + return Tagged_template_libJs.makeSql(prim); +} + +let sql = Tagged_template_libJs.makeSql("X: "); + +export { + makeSql, + sql, +} +/* sql Not a pure module */ diff --git a/tests/tests/src/tagged_template_binding.res b/tests/tests/src/tagged_template_binding.res new file mode 100644 index 00000000000..e97a2b11964 --- /dev/null +++ b/tests/tests/src/tagged_template_binding.res @@ -0,0 +1,9 @@ +// Cross-module provider: a `taggedTemplate` value exported from this module and +// consumed (with backtick syntax) in `Tagged_template_test`. The whole point of +// the first-class type is that the consuming module sees only the *type* of +// `sql` and still emits real tagged-template syntax at the call site. + +@module("./tagged_template_lib.js") +external makeSql: string => taggedTemplate = "makeSql" + +let sql = makeSql("X: ") diff --git a/tests/tests/src/tagged_template_global_import.mjs b/tests/tests/src/tagged_template_global_import.mjs new file mode 100644 index 00000000000..c3bde9b06cc --- /dev/null +++ b/tests/tests/src/tagged_template_global_import.mjs @@ -0,0 +1,17 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import Postgres from "postgres"; + +function makeSql(url) { + return Postgres(url); +} + +function findUser(sql, id) { + return sql`SELECT * FROM users WHERE id = ${id}`; +} + +export { + makeSql, + findUser, +} +/* postgres Not a pure module */ diff --git a/tests/tests/src/tagged_template_global_import.res b/tests/tests/src/tagged_template_global_import.res new file mode 100644 index 00000000000..8b58724abb7 --- /dev/null +++ b/tests/tests/src/tagged_template_global_import.res @@ -0,0 +1,19 @@ +// Codegen-only fixture (not a `*_test` file, so it is compiled but never run). +// It pins down the generated JS for a tag bound to a *bare package specifier* +// (`@module("postgres")`) — as opposed to a relative `./file.js` path — proving +// the import statement and the real tagged-template call site are emitted +// correctly. The committed `.mjs` snapshot is the assertion. + +type queryResult = {rows: array} + +// The `postgres` default export is a factory whose return value *is* the tag. +@module("postgres") +external postgres: string => taggedTemplate> = "default" + +// Construct the tag from the bare-imported factory. +let makeSql = url => postgres(url) + +// Use it with backtick syntax — emits a real `sql`...`` tagged template against +// the bare-package import. +let findUser = (sql: taggedTemplate>, id) => + sql`SELECT * FROM users WHERE id = ${id}` diff --git a/tests/tests/src/tagged_template_lib.js b/tests/tests/src/tagged_template_lib.js index 6d8bd67f009..4deef2430cc 100644 --- a/tests/tests/src/tagged_template_lib.js +++ b/tests/tests/src/tagged_template_lib.js @@ -21,3 +21,14 @@ export const makeSql = (prefix) => (strings, ...values) => { result += strings[values.length]; return result; }; + +// Reports whether it was invoked as a *real* tagged template. A genuine +// tagged-template call receives a frozen `TemplateStringsArray` with a `.raw` +// property; a plain function call does not. Used to prove the compiler emits +// real tagged-template syntax rather than a variadic function call. +export const rawTag = (strings, ...values) => ({ + hasRaw: strings.raw !== undefined, + raw: strings.raw ? Array.from(strings.raw) : [], + cooked: Array.from(strings), + values, +}); diff --git a/tests/tests/src/tagged_template_test.mjs b/tests/tests/src/tagged_template_test.mjs index 5ccb1759614..b30667b261a 100644 --- a/tests/tests/src/tagged_template_test.mjs +++ b/tests/tests/src/tagged_template_test.mjs @@ -4,6 +4,7 @@ import * as Mocha from "mocha"; import * as Test_utils from "./test_utils.mjs"; import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.mjs"; import * as Stdlib_TaggedTemplate from "@rescript/runtime/lib/es6/Stdlib_TaggedTemplate.mjs"; +import * as Tagged_template_binding from "./tagged_template_binding.mjs"; import * as Tagged_template_libJs from "./tagged_template_lib.js"; let sql = Tagged_template_libJs.sql; @@ -54,16 +55,40 @@ let greeting = s`hello ${{ _0: 36 }} years old!`; +let crossModuleQuery = Tagged_template_binding.sql`SELECT * FROM ${table}`; + +let rawTag = Tagged_template_libJs.rawTag; + +let rawResult = rawTag`a ${1} b ${2} c`; + Mocha.describe("tagged templates", () => { - Mocha.test("with externals, it should return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 60, characters 6-13", query, ` + Mocha.test("with externals, it should return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 80, characters 6-13", query, ` " SELECT * FROM 'users' WHERE id = '5'`)); - Mocha.test("with module scoped externals, it should also return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 69, characters 13-20", queryWithModule, "SELECT * FROM 'users' WHERE id = '5'")); - Mocha.test("with externals, it should return the result of the function", () => Test_utils.eq("File \"tagged_template_test.res\", line 72, characters 79-86", length$1, 52)); - Mocha.test("with a runtime-constructed tag (factory), it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 75, characters 7-14", factoryQuery, "PREFIX SELECT * FROM 'users'")); - Mocha.test("with a tag passed as a function argument, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 79, characters 7-14", paramQuery, "SELECT id = '5'")); - Mocha.test("with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", () => Test_utils.eq("File \"tagged_template_test.res\", line 84, characters 13-20", greeting, "hello Ada you're 36 years old!")); - Mocha.test("a template literal tagged with json should generate a regular string interpolation for now", () => Test_utils.eq("File \"tagged_template_test.res\", line 89, characters 13-20", "some random " + "string", "some random string")); - Mocha.test("a regular string interpolation should continue working", () => Test_utils.eq("File \"tagged_template_test.res\", line 93, characters 7-14", `some random ` + "string" + ` interpolation`, "some random string interpolation")); + Mocha.test("with module scoped externals, it should also return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 89, characters 13-20", queryWithModule, "SELECT * FROM 'users' WHERE id = '5'")); + Mocha.test("with externals, it should return the result of the function", () => Test_utils.eq("File \"tagged_template_test.res\", line 92, characters 79-86", length$1, 52)); + Mocha.test("with a runtime-constructed tag (factory), it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 95, characters 7-14", factoryQuery, "PREFIX SELECT * FROM 'users'")); + Mocha.test("with a tag passed as a function argument, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 99, characters 7-14", paramQuery, "SELECT id = '5'")); + Mocha.test("with a tag imported from another module, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 103, characters 7-14", crossModuleQuery, "X: SELECT * FROM 'users'")); + Mocha.test("it should call the tag as a real tagged template (TemplateStringsArray with .raw)", () => { + Test_utils.eq("File \"tagged_template_test.res\", line 107, characters 7-14", rawResult.hasRaw, true); + Test_utils.eq("File \"tagged_template_test.res\", line 108, characters 7-14", rawResult.cooked, [ + "a ", + " b ", + " c" + ]); + Test_utils.eq("File \"tagged_template_test.res\", line 109, characters 7-14", rawResult.raw, [ + "a ", + " b ", + " c" + ]); + Test_utils.eq("File \"tagged_template_test.res\", line 110, characters 7-14", rawResult.values, [ + 1, + 2 + ]); + }); + Mocha.test("with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", () => Test_utils.eq("File \"tagged_template_test.res\", line 115, characters 13-20", greeting, "hello Ada you're 36 years old!")); + Mocha.test("a template literal tagged with json should generate a regular string interpolation for now", () => Test_utils.eq("File \"tagged_template_test.res\", line 120, characters 13-20", "some random " + "string", "some random string")); + Mocha.test("a regular string interpolation should continue working", () => Test_utils.eq("File \"tagged_template_test.res\", line 124, characters 7-14", `some random ` + "string" + ` interpolation`, "some random string interpolation")); }); let extraLength = 10; @@ -83,5 +108,8 @@ export { paramQuery, s, greeting, + crossModuleQuery, + rawTag, + rawResult, } /* sql Not a pure module */ diff --git a/tests/tests/src/tagged_template_test.res b/tests/tests/src/tagged_template_test.res index 6ced3e80e3d..71dc748afc9 100644 --- a/tests/tests/src/tagged_template_test.res +++ b/tests/tests/src/tagged_template_test.res @@ -54,6 +54,26 @@ let s = TaggedTemplate.make((strings, parameters) => { let greeting = s`hello ${S("Ada")} you're ${I(36)} years old!` +// Problem 2b: a `taggedTemplate` value defined in another module, consumed here +// with backtick syntax. `Tagged_template_binding` only exposes `sql`'s type, yet +// the call site still emits a real tagged template. +let crossModuleQuery = Tagged_template_binding.sql`SELECT * FROM ${table}` + +// Proof that the compiler emits a *real* JS tagged template (a frozen +// `TemplateStringsArray` with `.raw`) rather than a plain/variadic function +// call. `rawTag` inspects the argument it receives. +type rawCall = { + hasRaw: bool, + raw: array, + cooked: array, + values: array, +} + +@module("./tagged_template_lib.js") +external rawTag: taggedTemplate = "rawTag" + +let rawResult = rawTag`a ${1} b ${2} c` + describe("tagged templates", () => { test("with externals, it should return a string with the correct interpolations", () => eq( @@ -79,6 +99,17 @@ describe("tagged templates", () => { eq(__LOC__, paramQuery, "SELECT id = '5'") ) + test("with a tag imported from another module, it should emit tagged-template syntax", () => + eq(__LOC__, crossModuleQuery, "X: SELECT * FROM 'users'") + ) + + test("it should call the tag as a real tagged template (TemplateStringsArray with .raw)", () => { + eq(__LOC__, rawResult.hasRaw, true) + eq(__LOC__, rawResult.cooked, ["a ", " b ", " c"]) + eq(__LOC__, rawResult.raw, ["a ", " b ", " c"]) + eq(__LOC__, rawResult.values, [1, 2]) + }) + test( "with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", () => eq(__LOC__, greeting, "hello Ada you're 36 years old!"), From 07a467db41fa49ae7ad834e53b422aaac17dc2f3 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 10:27:30 +0000 Subject: [PATCH 08/21] Add tagged-template error fixtures (type and syntax) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the ways a tagged template can be used incorrectly: - super_errors: calling a `taggedTemplate` value as a regular function (`sql(strings, args)`) — it is not a function type; and interpolating a value of the wrong `'param` type. - syntax_tests: unclosed tagged-template backticks (both an unterminated literal and an unterminated `${...}` interpolation), which recover with "Did you forget to close this template expression with a backtick?". Part of rescript-lang/rescript#8415. --- ...d_template_called_as_function.res.expected | 11 +++++++++++ .../tagged_template_wrong_param.res.expected | 19 +++++++++++++++++++ .../tagged_template_called_as_function.res | 7 +++++++ .../fixtures/tagged_template_wrong_param.res | 6 ++++++ .../expected/taggedTemplateUnclosed.res.txt | 13 +++++++++++++ ...aggedTemplateUnclosedInterpolation.res.txt | 13 +++++++++++++ .../expressions/taggedTemplateUnclosed.res | 1 + .../taggedTemplateUnclosedInterpolation.res | 1 + 8 files changed, 71 insertions(+) create mode 100644 tests/build_tests/super_errors/expected/tagged_template_called_as_function.res.expected create mode 100644 tests/build_tests/super_errors/expected/tagged_template_wrong_param.res.expected create mode 100644 tests/build_tests/super_errors/fixtures/tagged_template_called_as_function.res create mode 100644 tests/build_tests/super_errors/fixtures/tagged_template_wrong_param.res create mode 100644 tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosed.res.txt create mode 100644 tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosedInterpolation.res.txt create mode 100644 tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosed.res create mode 100644 tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res diff --git a/tests/build_tests/super_errors/expected/tagged_template_called_as_function.res.expected b/tests/build_tests/super_errors/expected/tagged_template_called_as_function.res.expected new file mode 100644 index 00000000000..2709b80b04b --- /dev/null +++ b/tests/build_tests/super_errors/expected/tagged_template_called_as_function.res.expected @@ -0,0 +1,11 @@ + + We've found a bug for you! + /.../fixtures/tagged_template_called_as_function.res:7:14-16 + + 5 │ external sql: taggedTemplate = "sql" + 6 │ + 7 │ let result = sql(["SELECT * FROM users WHERE id = ", ""], ["5"]) + 8 │ + + This can't be called, it's not a function. + The function has type: taggedTemplate \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/tagged_template_wrong_param.res.expected b/tests/build_tests/super_errors/expected/tagged_template_wrong_param.res.expected new file mode 100644 index 00000000000..8ed511f022e --- /dev/null +++ b/tests/build_tests/super_errors/expected/tagged_template_wrong_param.res.expected @@ -0,0 +1,19 @@ + + We've found a bug for you! + /.../fixtures/tagged_template_wrong_param.res:6:51-62 + + 4 │ external sql: taggedTemplate = "sql" + 5 │ + 6 │ let result = sql`SELECT * FROM users WHERE id = ${"not-an-int"}` + 7 │ + + This array item has type: string + But this array is expected to have items of type: int + + Arrays can only contain items of the same type. + + Possible solutions: + - Convert all values in the array to the same type. + - Use a tuple, if your array is of fixed length. Tuples can mix types freely, and compiles to a JavaScript array. Example of a tuple: `let myTuple = (10, "hello", 15.5, true) + + You can convert string to int with Int.fromString. \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/tagged_template_called_as_function.res b/tests/build_tests/super_errors/fixtures/tagged_template_called_as_function.res new file mode 100644 index 00000000000..4c462c366e7 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/tagged_template_called_as_function.res @@ -0,0 +1,7 @@ +// A `taggedTemplate` value can only be used with backtick syntax, not called +// as a regular function. The type is not a function type, so applying it to +// arguments is a type error. +@module("./sql.js") +external sql: taggedTemplate = "sql" + +let result = sql(["SELECT * FROM users WHERE id = ", ""], ["5"]) diff --git a/tests/build_tests/super_errors/fixtures/tagged_template_wrong_param.res b/tests/build_tests/super_errors/fixtures/tagged_template_wrong_param.res new file mode 100644 index 00000000000..0056c4c1b01 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/tagged_template_wrong_param.res @@ -0,0 +1,6 @@ +// The interpolated values must match the tag's `'param` type. Here `sql` +// expects `int` interpolations, but a string is interpolated. +@module("./sql.js") +external sql: taggedTemplate = "sql" + +let result = sql`SELECT * FROM users WHERE id = ${"not-an-int"}` diff --git a/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosed.res.txt b/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosed.res.txt new file mode 100644 index 00000000000..4daa96c6b4c --- /dev/null +++ b/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosed.res.txt @@ -0,0 +1,13 @@ + + Syntax error! + syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosed.res:1:49 + + 1 │ let q = sql`SELECT * FROM users WHERE id = ${id} + + Did you forget to close this template expression with a backtick? + +let q = + ((sql + [|(({js|SELECT * FROM users WHERE id = |js}) + [@res.template ]);(({js||js})[@res.template ])|] [|id|]) + [@res.taggedTemplate ]) \ No newline at end of file diff --git a/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosedInterpolation.res.txt b/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosedInterpolation.res.txt new file mode 100644 index 00000000000..57081177908 --- /dev/null +++ b/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosedInterpolation.res.txt @@ -0,0 +1,13 @@ + + Syntax error! + syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res:1:34 + + 1 │ let q = sql`SELECT * FROM ${table + + Did you forget to close this template expression with a backtick? + +let q = + ((sql + [|(({js|SELECT * FROM |js})[@res.template ]);(({js||js}) + [@res.template ])|] [|table|]) + [@res.taggedTemplate ]) \ No newline at end of file diff --git a/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosed.res b/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosed.res new file mode 100644 index 00000000000..42ce7a3fab0 --- /dev/null +++ b/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosed.res @@ -0,0 +1 @@ +let q = sql`SELECT * FROM users WHERE id = ${id} \ No newline at end of file diff --git a/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res b/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res new file mode 100644 index 00000000000..8b48393d8eb --- /dev/null +++ b/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res @@ -0,0 +1 @@ +let q = sql`SELECT * FROM ${table \ No newline at end of file From 20742564b86021746120386ac0840a72c5bf9eab Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 10:35:22 +0000 Subject: [PATCH 09/21] Reword tagged-template test comments to describe the tests Drop the references to the issue's "Problem N" labels; each comment now just explains what the test exercises. --- tests/tests/src/tagged_template_test.mjs | 26 ++++++++++++------------ tests/tests/src/tagged_template_test.res | 17 ++++++++-------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/tests/tests/src/tagged_template_test.mjs b/tests/tests/src/tagged_template_test.mjs index b30667b261a..d2b7fffef18 100644 --- a/tests/tests/src/tagged_template_test.mjs +++ b/tests/tests/src/tagged_template_test.mjs @@ -62,33 +62,33 @@ let rawTag = Tagged_template_libJs.rawTag; let rawResult = rawTag`a ${1} b ${2} c`; Mocha.describe("tagged templates", () => { - Mocha.test("with externals, it should return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 80, characters 6-13", query, ` + Mocha.test("with externals, it should return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 81, characters 6-13", query, ` " SELECT * FROM 'users' WHERE id = '5'`)); - Mocha.test("with module scoped externals, it should also return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 89, characters 13-20", queryWithModule, "SELECT * FROM 'users' WHERE id = '5'")); - Mocha.test("with externals, it should return the result of the function", () => Test_utils.eq("File \"tagged_template_test.res\", line 92, characters 79-86", length$1, 52)); - Mocha.test("with a runtime-constructed tag (factory), it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 95, characters 7-14", factoryQuery, "PREFIX SELECT * FROM 'users'")); - Mocha.test("with a tag passed as a function argument, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 99, characters 7-14", paramQuery, "SELECT id = '5'")); - Mocha.test("with a tag imported from another module, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 103, characters 7-14", crossModuleQuery, "X: SELECT * FROM 'users'")); + Mocha.test("with module scoped externals, it should also return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 90, characters 13-20", queryWithModule, "SELECT * FROM 'users' WHERE id = '5'")); + Mocha.test("with externals, it should return the result of the function", () => Test_utils.eq("File \"tagged_template_test.res\", line 93, characters 79-86", length$1, 52)); + Mocha.test("with a runtime-constructed tag (factory), it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 96, characters 7-14", factoryQuery, "PREFIX SELECT * FROM 'users'")); + Mocha.test("with a tag passed as a function argument, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 100, characters 7-14", paramQuery, "SELECT id = '5'")); + Mocha.test("with a tag imported from another module, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 104, characters 7-14", crossModuleQuery, "X: SELECT * FROM 'users'")); Mocha.test("it should call the tag as a real tagged template (TemplateStringsArray with .raw)", () => { - Test_utils.eq("File \"tagged_template_test.res\", line 107, characters 7-14", rawResult.hasRaw, true); - Test_utils.eq("File \"tagged_template_test.res\", line 108, characters 7-14", rawResult.cooked, [ + Test_utils.eq("File \"tagged_template_test.res\", line 108, characters 7-14", rawResult.hasRaw, true); + Test_utils.eq("File \"tagged_template_test.res\", line 109, characters 7-14", rawResult.cooked, [ "a ", " b ", " c" ]); - Test_utils.eq("File \"tagged_template_test.res\", line 109, characters 7-14", rawResult.raw, [ + Test_utils.eq("File \"tagged_template_test.res\", line 110, characters 7-14", rawResult.raw, [ "a ", " b ", " c" ]); - Test_utils.eq("File \"tagged_template_test.res\", line 110, characters 7-14", rawResult.values, [ + Test_utils.eq("File \"tagged_template_test.res\", line 111, characters 7-14", rawResult.values, [ 1, 2 ]); }); - Mocha.test("with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", () => Test_utils.eq("File \"tagged_template_test.res\", line 115, characters 13-20", greeting, "hello Ada you're 36 years old!")); - Mocha.test("a template literal tagged with json should generate a regular string interpolation for now", () => Test_utils.eq("File \"tagged_template_test.res\", line 120, characters 13-20", "some random " + "string", "some random string")); - Mocha.test("a regular string interpolation should continue working", () => Test_utils.eq("File \"tagged_template_test.res\", line 124, characters 7-14", `some random ` + "string" + ` interpolation`, "some random string interpolation")); + Mocha.test("with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", () => Test_utils.eq("File \"tagged_template_test.res\", line 116, characters 13-20", greeting, "hello Ada you're 36 years old!")); + Mocha.test("a template literal tagged with json should generate a regular string interpolation for now", () => Test_utils.eq("File \"tagged_template_test.res\", line 121, characters 13-20", "some random " + "string", "some random string")); + Mocha.test("a regular string interpolation should continue working", () => Test_utils.eq("File \"tagged_template_test.res\", line 125, characters 7-14", `some random ` + "string" + ` interpolation`, "some random string interpolation")); }); let extraLength = 10; diff --git a/tests/tests/src/tagged_template_test.res b/tests/tests/src/tagged_template_test.res index 71dc748afc9..c403a6c8c1b 100644 --- a/tests/tests/src/tagged_template_test.res +++ b/tests/tests/src/tagged_template_test.res @@ -25,20 +25,21 @@ external length: taggedTemplate = "length" let extraLength = 10 let length = length`hello ${extraLength} what's the total length? Is it ${3}?` -// Problem 1: a tag constructed at runtime by a factory (postgres-style). +// A tag constructed at runtime by a factory (postgres-style), where the factory +// returns the tag value. @module("./tagged_template_lib.js") external makeSql: string => taggedTemplate = "makeSql" let prefixedSql = makeSql("PREFIX ") let factoryQuery = prefixedSql`SELECT * FROM ${table}` -// Problem 2c: the tag flows through a function parameter and is still emitted -// as a real tagged template at the call site inside the function. +// The tag flows through a function parameter and is still emitted as a real +// tagged template at the call site inside the function. let runQuery = (tag: taggedTemplate) => tag`SELECT id = ${id}` let paramQuery = runQuery(Pg.sql) -// Problem 3: a ReScript-authored tag, lifted into the type with -// `TaggedTemplate.make`, is usable as a tag too. +// A ReScript-authored tag, lifted into the type with `TaggedTemplate.make`, is +// usable as a tag too. type params = I(int) | S(string) let s = TaggedTemplate.make((strings, parameters) => { @@ -54,9 +55,9 @@ let s = TaggedTemplate.make((strings, parameters) => { let greeting = s`hello ${S("Ada")} you're ${I(36)} years old!` -// Problem 2b: a `taggedTemplate` value defined in another module, consumed here -// with backtick syntax. `Tagged_template_binding` only exposes `sql`'s type, yet -// the call site still emits a real tagged template. +// A `taggedTemplate` value defined in another module, consumed here with +// backtick syntax. `Tagged_template_binding` only exposes `sql`'s type, yet the +// call site still emits a real tagged template. let crossModuleQuery = Tagged_template_binding.sql`SELECT * FROM ${table}` // Proof that the compiler emits a *real* JS tagged template (a frozen From e98ba67c7e01874ed90bd1f596d81ee92d3c239d Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 10:40:29 +0000 Subject: [PATCH 10/21] Test interpolation error distinctly from unclosed backtick The previous `taggedTemplateUnclosedInterpolation` fixture left both the `${...}` and the backtick unterminated, so it just re-exercised the "forgot the backtick" path. Replace it with an empty interpolation (`${}`) inside a properly closed template, which exercises the distinct "this expression block is empty" interpolation error. --- .../taggedTemplateEmptyInterpolation.res.txt | 13 +++++++++++++ .../taggedTemplateUnclosedInterpolation.res.txt | 13 ------------- .../taggedTemplateEmptyInterpolation.res | 1 + .../taggedTemplateUnclosedInterpolation.res | 1 - 4 files changed, 14 insertions(+), 14 deletions(-) create mode 100644 tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateEmptyInterpolation.res.txt delete mode 100644 tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosedInterpolation.res.txt create mode 100644 tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateEmptyInterpolation.res delete mode 100644 tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res diff --git a/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateEmptyInterpolation.res.txt b/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateEmptyInterpolation.res.txt new file mode 100644 index 00000000000..dd4580b5fea --- /dev/null +++ b/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateEmptyInterpolation.res.txt @@ -0,0 +1,13 @@ + + Syntax error! + syntax_tests/data/parsing/errors/expressions/taggedTemplateEmptyInterpolation.res:1:29 + + 1 │ let q = sql`SELECT * FROM ${}` + + It seems that this expression block is empty + +let q = + ((sql + [|(({js|SELECT * FROM |js})[@res.template ]);(({js||js}) + [@res.template ])|] [|([%rescript.exprhole ])|]) + [@res.taggedTemplate ]) \ No newline at end of file diff --git a/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosedInterpolation.res.txt b/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosedInterpolation.res.txt deleted file mode 100644 index 57081177908..00000000000 --- a/tests/syntax_tests/data/parsing/errors/expressions/expected/taggedTemplateUnclosedInterpolation.res.txt +++ /dev/null @@ -1,13 +0,0 @@ - - Syntax error! - syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res:1:34 - - 1 │ let q = sql`SELECT * FROM ${table - - Did you forget to close this template expression with a backtick? - -let q = - ((sql - [|(({js|SELECT * FROM |js})[@res.template ]);(({js||js}) - [@res.template ])|] [|table|]) - [@res.taggedTemplate ]) \ No newline at end of file diff --git a/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateEmptyInterpolation.res b/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateEmptyInterpolation.res new file mode 100644 index 00000000000..eed3f115b0d --- /dev/null +++ b/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateEmptyInterpolation.res @@ -0,0 +1 @@ +let q = sql`SELECT * FROM ${}` \ No newline at end of file diff --git a/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res b/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res deleted file mode 100644 index 8b48393d8eb..00000000000 --- a/tests/syntax_tests/data/parsing/errors/expressions/taggedTemplateUnclosedInterpolation.res +++ /dev/null @@ -1 +0,0 @@ -let q = sql`SELECT * FROM ${table \ No newline at end of file From 89c535e0c2ffa43cf7f38d02c0ef0beb605069e3 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 10:45:48 +0000 Subject: [PATCH 11/21] Tagged-template-specific error messages for misuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two error paths reported confusing, leaky messages for tagged templates: - Calling a tag as a function (`sql(strings, args)`) gave the generic "this can't be called, it's not a function". It now explains the value is a tagged-template tag and to use backtick syntax instead. - Interpolating a value of the wrong `'param` type reported an "array item" type error (exposing the desugared values array, which the user never wrote). It now uses a dedicated `TaggedTemplateValue` clash context: "This interpolated value has type: … But this tag expects interpolations of type: …", typing each `${...}` directly instead of routing through the array-literal typer. --- compiler/ml/error_message_utils.ml | 5 +++ compiler/ml/typecore.ml | 32 ++++++++++++++++++- ...d_template_called_as_function.res.expected | 4 +-- .../tagged_template_wrong_param.res.expected | 10 ++---- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/compiler/ml/error_message_utils.ml b/compiler/ml/error_message_utils.ml index 19995a6145c..fc7fe6373da 100644 --- a/compiler/ml/error_message_utils.ml +++ b/compiler/ml/error_message_utils.ml @@ -90,6 +90,7 @@ type type_clash_context = optional: bool; } | ArrayValue + | TaggedTemplateValue | MaybeUnwrapOption | IfCondition | AssertCondition @@ -121,6 +122,7 @@ let context_to_string = function | Some (Statement _) -> "Statement" | Some (MathOperator _) -> "MathOperator" | Some ArrayValue -> "ArrayValue" + | Some TaggedTemplateValue -> "TaggedTemplateValue" | Some (SetRecordField _) -> "SetRecordField" | Some (RecordField _) -> "RecordField" | Some MaybeUnwrapOption -> "MaybeUnwrapOption" @@ -145,6 +147,7 @@ let error_type_text ppf type_clash_context = | Some (Statement FunctionCall) -> "This function call returns:" | Some (MathOperator {is_constant = Some _}) -> "This value has type:" | Some ArrayValue -> "This array item has type:" + | Some TaggedTemplateValue -> "This interpolated value has type:" | Some (SetRecordField _) -> "You're assigning something to this field that has type:" | Some JsxComponent -> "This JSX tag has type:" @@ -184,6 +187,8 @@ let error_expected_type_text ppf type_clash_context = | Some TernaryReturn -> fprintf ppf "But this ternary is expected to return:" | Some ArrayValue -> fprintf ppf "But this array is expected to have items of type:" + | Some TaggedTemplateValue -> + fprintf ppf "But this tag expects interpolations of type:" | Some (SetRecordField _) -> fprintf ppf "But the record field is of type:" | Some (RecordField {field_name = "children"; jsx = Some {jsx_type = `Fragment}}) diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml index fee0a3dbe9b..2113a6b396d 100644 --- a/compiler/ml/typecore.ml +++ b/compiler/ml/typecore.ml @@ -2539,8 +2539,32 @@ and type_expect_ ?deprecated_context ~context ?in_function ?(recarg = Rejected) type_expect ~context:None env strings (Predef.type_array Predef.type_string) in + (* Type each interpolated value directly against [param_ty] with a + tagged-template-specific clash context, rather than routing the + desugared values array through the generic array typing (which + would report a confusing "array item" type error for what the user + wrote as a [${...}] interpolation). *) let typed_values = - type_expect ~context:None env values (Predef.type_array param_ty) + match values.pexp_desc with + | Pexp_array interpolations -> + let typed_interpolations = + List.map + (fun interp -> + type_expect ~context:(Some TaggedTemplateValue) env interp + param_ty) + interpolations + in + re + { + exp_desc = Texp_array typed_interpolations; + exp_loc = values.pexp_loc; + exp_extra = []; + exp_type = newconstr Predef.path_array [param_ty]; + exp_attributes = values.pexp_attributes; + exp_env = env; + } + | _ -> + type_expect ~context:None env values (Predef.type_array param_ty) in ( [ (Asttypes.Nolabel, Some typed_strings); @@ -4696,6 +4720,12 @@ let report_error env loc ppf error = fprintf ppf "@ @[It only accepts %i %s; here, it's called with more.@]@]" accepts_count (if accepts_count == 1 then "argument" else "arguments") + | Tconstr (path, _, _) when Path.same path Predef.path_tagged_template -> + fprintf ppf + "@[@[<2>This is a tagged-template tag of type@ @{%a@}@]@,\ + It can't be called like a function. Use it with backtick syntax \ + instead, e.g. @{tag`SELECT ${id}`@}.@]" + type_expr typ | _ -> fprintf ppf "@[@[<2>This can't be called, it's not a function.@]@,\ diff --git a/tests/build_tests/super_errors/expected/tagged_template_called_as_function.res.expected b/tests/build_tests/super_errors/expected/tagged_template_called_as_function.res.expected index 2709b80b04b..0e8e9e03f2a 100644 --- a/tests/build_tests/super_errors/expected/tagged_template_called_as_function.res.expected +++ b/tests/build_tests/super_errors/expected/tagged_template_called_as_function.res.expected @@ -7,5 +7,5 @@ 7 │ let result = sql(["SELECT * FROM users WHERE id = ", ""], ["5"]) 8 │ - This can't be called, it's not a function. - The function has type: taggedTemplate \ No newline at end of file + This is a tagged-template tag of type taggedTemplate + It can't be called like a function. Use it with backtick syntax instead, e.g. tag`SELECT ${id}`. \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/tagged_template_wrong_param.res.expected b/tests/build_tests/super_errors/expected/tagged_template_wrong_param.res.expected index 8ed511f022e..99ab9eb7d80 100644 --- a/tests/build_tests/super_errors/expected/tagged_template_wrong_param.res.expected +++ b/tests/build_tests/super_errors/expected/tagged_template_wrong_param.res.expected @@ -7,13 +7,7 @@ 6 │ let result = sql`SELECT * FROM users WHERE id = ${"not-an-int"}` 7 │ - This array item has type: string - But this array is expected to have items of type: int - - Arrays can only contain items of the same type. - - Possible solutions: - - Convert all values in the array to the same type. - - Use a tuple, if your array is of fixed length. Tuples can mix types freely, and compiles to a JavaScript array. Example of a tuple: `let myTuple = (10, "hello", 15.5, true) + This interpolated value has type: string + But this tag expects interpolations of type: int You can convert string to int with Int.fromString. \ No newline at end of file From 832073eb02194d94957938cde54eff4b8d34b1cb Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 11:02:14 +0000 Subject: [PATCH 12/21] Execute a bare-package-import tag at runtime Add a runtime test for a tag bound to a bare (non-relative) import specifier, complementing the relative-path bindings. A Node `imports` subpath entry (`#tagged-template-pg`) resolves the bare specifier to a committed mock without installing a package. The mock throws unless it receives a real `TemplateStringsArray`, so the test passing proves the call site emitted real tagged-template syntax against the bare import. --- tests/tests/package.json | 5 +++- tests/tests/src/tagged_template_pg_mock.js | 20 +++++++++++++ tests/tests/src/tagged_template_test.mjs | 34 +++++++++++++--------- tests/tests/src/tagged_template_test.res | 17 +++++++++++ 4 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 tests/tests/src/tagged_template_pg_mock.js diff --git a/tests/tests/package.json b/tests/tests/package.json index 0aced3f05d5..7b03eaa4cbd 100644 --- a/tests/tests/package.json +++ b/tests/tests/package.json @@ -5,5 +5,8 @@ "@rescript/react": "workspace:^", "rescript": "workspace:^" }, - "type": "module" + "type": "module", + "imports": { + "#tagged-template-pg": "./src/tagged_template_pg_mock.js" + } } diff --git a/tests/tests/src/tagged_template_pg_mock.js b/tests/tests/src/tagged_template_pg_mock.js new file mode 100644 index 00000000000..8219ba9d248 --- /dev/null +++ b/tests/tests/src/tagged_template_pg_mock.js @@ -0,0 +1,20 @@ +// Mock of a third-party package (e.g. `postgres`) used to prove that a tag +// bound to a *bare* (non-relative) import specifier is still invoked with real +// tagged-template syntax at runtime. It is wired up via the Node `imports` +// subpath map in this package's `package.json` (`#tagged-template-pg`), so the +// bare specifier resolves to this file without installing a package. +// +// The returned tag throws unless it receives a genuine `TemplateStringsArray` +// (which only a real tagged-template call provides, via `.raw`). So a test that +// runs without throwing has proven the call site emitted backtick syntax. +export default (prefix) => (strings, ...values) => { + if (strings.raw === undefined) { + throw new Error("tag was not called as a tagged template (no .raw)"); + } + let result = prefix; + for (let i = 0; i < values.length; i++) { + result += strings[i] + "'" + values[i] + "'"; + } + result += strings[values.length]; + return result; +}; diff --git a/tests/tests/src/tagged_template_test.mjs b/tests/tests/src/tagged_template_test.mjs index d2b7fffef18..a843b4a5549 100644 --- a/tests/tests/src/tagged_template_test.mjs +++ b/tests/tests/src/tagged_template_test.mjs @@ -3,6 +3,7 @@ import * as Mocha from "mocha"; import * as Test_utils from "./test_utils.mjs"; import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.mjs"; +import TaggedTemplatePg from "#tagged-template-pg"; import * as Stdlib_TaggedTemplate from "@rescript/runtime/lib/es6/Stdlib_TaggedTemplate.mjs"; import * as Tagged_template_binding from "./tagged_template_binding.mjs"; import * as Tagged_template_libJs from "./tagged_template_lib.js"; @@ -34,6 +35,10 @@ let prefixedSql = Tagged_template_libJs.makeSql("PREFIX "); let factoryQuery = prefixedSql`SELECT * FROM ${table}`; +let pgSql = TaggedTemplatePg("PG: "); + +let bareImportQuery = pgSql`SELECT * FROM ${table}`; + function runQuery(tag) { return tag`SELECT id = ${id}`; } @@ -62,33 +67,34 @@ let rawTag = Tagged_template_libJs.rawTag; let rawResult = rawTag`a ${1} b ${2} c`; Mocha.describe("tagged templates", () => { - Mocha.test("with externals, it should return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 81, characters 6-13", query, ` + Mocha.test("with externals, it should return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 92, characters 6-13", query, ` " SELECT * FROM 'users' WHERE id = '5'`)); - Mocha.test("with module scoped externals, it should also return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 90, characters 13-20", queryWithModule, "SELECT * FROM 'users' WHERE id = '5'")); - Mocha.test("with externals, it should return the result of the function", () => Test_utils.eq("File \"tagged_template_test.res\", line 93, characters 79-86", length$1, 52)); - Mocha.test("with a runtime-constructed tag (factory), it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 96, characters 7-14", factoryQuery, "PREFIX SELECT * FROM 'users'")); - Mocha.test("with a tag passed as a function argument, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 100, characters 7-14", paramQuery, "SELECT id = '5'")); - Mocha.test("with a tag imported from another module, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 104, characters 7-14", crossModuleQuery, "X: SELECT * FROM 'users'")); + Mocha.test("with module scoped externals, it should also return a string with the correct interpolations", () => Test_utils.eq("File \"tagged_template_test.res\", line 101, characters 13-20", queryWithModule, "SELECT * FROM 'users' WHERE id = '5'")); + Mocha.test("with externals, it should return the result of the function", () => Test_utils.eq("File \"tagged_template_test.res\", line 104, characters 79-86", length$1, 52)); + Mocha.test("with a runtime-constructed tag (factory), it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 107, characters 7-14", factoryQuery, "PREFIX SELECT * FROM 'users'")); + Mocha.test("with a tag from a bare package import, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 113, characters 7-14", bareImportQuery, "PG: SELECT * FROM 'users'")); + Mocha.test("with a tag passed as a function argument, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 117, characters 7-14", paramQuery, "SELECT id = '5'")); + Mocha.test("with a tag imported from another module, it should emit tagged-template syntax", () => Test_utils.eq("File \"tagged_template_test.res\", line 121, characters 7-14", crossModuleQuery, "X: SELECT * FROM 'users'")); Mocha.test("it should call the tag as a real tagged template (TemplateStringsArray with .raw)", () => { - Test_utils.eq("File \"tagged_template_test.res\", line 108, characters 7-14", rawResult.hasRaw, true); - Test_utils.eq("File \"tagged_template_test.res\", line 109, characters 7-14", rawResult.cooked, [ + Test_utils.eq("File \"tagged_template_test.res\", line 125, characters 7-14", rawResult.hasRaw, true); + Test_utils.eq("File \"tagged_template_test.res\", line 126, characters 7-14", rawResult.cooked, [ "a ", " b ", " c" ]); - Test_utils.eq("File \"tagged_template_test.res\", line 110, characters 7-14", rawResult.raw, [ + Test_utils.eq("File \"tagged_template_test.res\", line 127, characters 7-14", rawResult.raw, [ "a ", " b ", " c" ]); - Test_utils.eq("File \"tagged_template_test.res\", line 111, characters 7-14", rawResult.values, [ + Test_utils.eq("File \"tagged_template_test.res\", line 128, characters 7-14", rawResult.values, [ 1, 2 ]); }); - Mocha.test("with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", () => Test_utils.eq("File \"tagged_template_test.res\", line 116, characters 13-20", greeting, "hello Ada you're 36 years old!")); - Mocha.test("a template literal tagged with json should generate a regular string interpolation for now", () => Test_utils.eq("File \"tagged_template_test.res\", line 121, characters 13-20", "some random " + "string", "some random string")); - Mocha.test("a regular string interpolation should continue working", () => Test_utils.eq("File \"tagged_template_test.res\", line 125, characters 7-14", `some random ` + "string" + ` interpolation`, "some random string interpolation")); + Mocha.test("with a ReScript tag lifted via TaggedTemplate.make, it should return the correct interpolation", () => Test_utils.eq("File \"tagged_template_test.res\", line 133, characters 13-20", greeting, "hello Ada you're 36 years old!")); + Mocha.test("a template literal tagged with json should generate a regular string interpolation for now", () => Test_utils.eq("File \"tagged_template_test.res\", line 138, characters 13-20", "some random " + "string", "some random string")); + Mocha.test("a regular string interpolation should continue working", () => Test_utils.eq("File \"tagged_template_test.res\", line 142, characters 7-14", `some random ` + "string" + ` interpolation`, "some random string interpolation")); }); let extraLength = 10; @@ -104,6 +110,8 @@ export { makeSql, prefixedSql, factoryQuery, + pgSql, + bareImportQuery, runQuery, paramQuery, s, diff --git a/tests/tests/src/tagged_template_test.res b/tests/tests/src/tagged_template_test.res index c403a6c8c1b..389f98ed002 100644 --- a/tests/tests/src/tagged_template_test.res +++ b/tests/tests/src/tagged_template_test.res @@ -33,6 +33,17 @@ external makeSql: string => taggedTemplate = "makeSql" let prefixedSql = makeSql("PREFIX ") let factoryQuery = prefixedSql`SELECT * FROM ${table}` +// A tag bound to a *bare* (non-relative) import specifier, rather than a +// `./relative.js` path. `#tagged-template-pg` resolves via the Node `imports` +// map in this package's package.json to a mock that throws unless it receives a +// real `TemplateStringsArray`, so a passing result proves the call site emitted +// real tagged-template syntax against the bare import. +@module("#tagged-template-pg") +external pg: string => taggedTemplate = "default" + +let pgSql = pg("PG: ") +let bareImportQuery = pgSql`SELECT * FROM ${table}` + // The tag flows through a function parameter and is still emitted as a real // tagged template at the call site inside the function. let runQuery = (tag: taggedTemplate) => tag`SELECT id = ${id}` @@ -96,6 +107,12 @@ describe("tagged templates", () => { eq(__LOC__, factoryQuery, "PREFIX SELECT * FROM 'users'") ) + test("with a tag from a bare package import, it should emit tagged-template syntax", () => + // The mock throws if not called as a real tagged template, so reaching this + // assertion at all already proves backtick syntax was emitted. + eq(__LOC__, bareImportQuery, "PG: SELECT * FROM 'users'") + ) + test("with a tag passed as a function argument, it should emit tagged-template syntax", () => eq(__LOC__, paramQuery, "SELECT id = '5'") ) From 91ac2c7202b527112e914b5e67fa89a53c0c4e7e Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 11:10:40 +0000 Subject: [PATCH 13/21] Update analysis completion snapshots for Stdlib.TaggedTemplate Adding the `TaggedTemplate` stdlib module makes it appear in module completion lists, so the affected analysis snapshots gain the new entry. --- tests/analysis_tests/tests/src/expected/Completion.res.txt | 6 ++++++ .../tests/src/expected/CompletionJsxProps.res.txt | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/analysis_tests/tests/src/expected/Completion.res.txt b/tests/analysis_tests/tests/src/expected/Completion.res.txt index 761f583a6e1..f679ec14ced 100644 --- a/tests/analysis_tests/tests/src/expected/Completion.res.txt +++ b/tests/analysis_tests/tests/src/expected/Completion.res.txt @@ -2571,6 +2571,12 @@ Path T "label": "TypedArray", "tags": [] }, + { + "detail": "module TaggedTemplate", + "kind": 9, + "label": "TaggedTemplate", + "tags": [] + }, { "detail": "module TimeoutId", "kind": 9, diff --git a/tests/analysis_tests/tests/src/expected/CompletionJsxProps.res.txt b/tests/analysis_tests/tests/src/expected/CompletionJsxProps.res.txt index 7d6405d1408..6cc8610bb34 100644 --- a/tests/analysis_tests/tests/src/expected/CompletionJsxProps.res.txt +++ b/tests/analysis_tests/tests/src/expected/CompletionJsxProps.res.txt @@ -80,6 +80,12 @@ Path CompletionSupport.TestComponent.make "label": "TypedArray", "tags": [] }, + { + "detail": "module TaggedTemplate", + "kind": 9, + "label": "TaggedTemplate", + "tags": [] + }, { "detail": "module TimeoutId", "kind": 9, From 58c7c9571e9e2daafb41de27cfbcee635893b6d2 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 11:36:46 +0000 Subject: [PATCH 14/21] Add gentype coverage for the taggedTemplate type mapping Exercise the `taggedTemplate` / `TaggedTemplate.t` -> opaque (`unknown`) gentype translation, which was previously untested. Both the global builtin and the stdlib alias are emitted as `(x:unknown) => unknown`. --- .../typescript-react-example/src/Core.gen.tsx | 4 ++++ .../typescript-react-example/src/Core.res | 6 ++++++ .../typescript-react-example/src/Core.res.js | 10 ++++++++++ 3 files changed, 20 insertions(+) diff --git a/tests/gentype_tests/typescript-react-example/src/Core.gen.tsx b/tests/gentype_tests/typescript-react-example/src/Core.gen.tsx index 6001f862ead..a13b0570ed1 100644 --- a/tests/gentype_tests/typescript-react-example/src/Core.gen.tsx +++ b/tests/gentype_tests/typescript-react-example/src/Core.gen.tsx @@ -47,6 +47,10 @@ export const promise0: (x:Promise) => Promise = CoreJS.promise0 export const promise1: (x:Promise) => Promise = CoreJS.promise1 as any; +export const taggedTemplate0: (x:unknown) => unknown = CoreJS.taggedTemplate0 as any; + +export const taggedTemplate1: (x:unknown) => unknown = CoreJS.taggedTemplate1 as any; + export const date0: (x:Date) => Date = CoreJS.date0 as any; export const date1: (x:Date) => Date = CoreJS.date1 as any; diff --git a/tests/gentype_tests/typescript-react-example/src/Core.res b/tests/gentype_tests/typescript-react-example/src/Core.res index 05527ff7d84..16fe0cb7797 100644 --- a/tests/gentype_tests/typescript-react-example/src/Core.res +++ b/tests/gentype_tests/typescript-react-example/src/Core.res @@ -28,6 +28,12 @@ let promise0 = (x: promise) => x @genType let promise1 = (x: Promise.t) => x +@genType +let taggedTemplate0 = (x: taggedTemplate) => x + +@genType +let taggedTemplate1 = (x: TaggedTemplate.t) => x + @genType let date0 = (x: Js.Date.t) => x diff --git a/tests/gentype_tests/typescript-react-example/src/Core.res.js b/tests/gentype_tests/typescript-react-example/src/Core.res.js index 2f8e0e1a366..6d21492479e 100644 --- a/tests/gentype_tests/typescript-react-example/src/Core.res.js +++ b/tests/gentype_tests/typescript-react-example/src/Core.res.js @@ -42,6 +42,14 @@ function promise1(x) { return x; } +function taggedTemplate0(x) { + return x; +} + +function taggedTemplate1(x) { + return x; +} + function date0(x) { return x; } @@ -113,6 +121,8 @@ export { dict1, promise0, promise1, + taggedTemplate0, + taggedTemplate1, date0, date1, bigint0, From 1b927245db2ae67a58478c85ea53ba110be015e9 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 12:04:08 +0000 Subject: [PATCH 15/21] Emit a real tag function type in gentype instead of unknown A `taggedTemplate<'param, 'output>` is a variadic tag function, so map it to the precise TypeScript signature (strings: TemplateStringsArray, ...values: 'param[]) => 'output rather than the opaque `unknown`. This is also the signature TypeScript requires for a value to be usable with backtick tagged-template syntax. gentype has no rest-argument field, so the spread is encoded in the parameter name (emitted verbatim before the type); the generated output type-checks under `tsc`. --- .../gentype/translate_type_expr_from_types.ml | 28 ++++++++++++++++--- .../typescript-react-example/src/Core.gen.tsx | 4 +-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/compiler/gentype/translate_type_expr_from_types.ml b/compiler/gentype/translate_type_expr_from_types.ml index fad9cf8e7fd..f88aedc2f56 100644 --- a/compiler/gentype/translate_type_expr_from_types.ml +++ b/compiler/gentype/translate_type_expr_from_types.ml @@ -436,10 +436,30 @@ let translate_constr ~config ~params_translation ~(path : Path.t) ~type_env = [param_translation] ) -> {param_translation with type_ = Dict param_translation.type_} | ["Stdlib"; "JSON"; "t"], [] -> {dependencies = []; type_ = unknown} - | (["taggedTemplate"] | ["Stdlib"; "TaggedTemplate"; "t"]), [_param; _output] - -> - (* A tagged-template tag is a variadic JS function; represent it opaquely. *) - {dependencies = []; type_ = unknown} + | ( (["taggedTemplate"] | ["Stdlib"; "TaggedTemplate"; "t"]), + [param_translation; output_translation] ) -> + (* A tagged-template tag is a variadic function usable with backtick syntax: + (strings: TemplateStringsArray, ...values: 'param[]) => 'output. + gentype has no rest-argument field, so the spread is encoded in the + parameter name, which is emitted verbatim before the type. *) + { + dependencies = + param_translation.dependencies @ output_translation.dependencies; + type_ = + Function + { + arg_types = + [ + {a_name = "strings"; a_type = ident "TemplateStringsArray"}; + { + a_name = "...values"; + a_type = Array (param_translation.type_, Mutable); + }; + ]; + ret_type = output_translation.type_; + type_vars = []; + }; + } | _ -> default_case () type process_variant = { diff --git a/tests/gentype_tests/typescript-react-example/src/Core.gen.tsx b/tests/gentype_tests/typescript-react-example/src/Core.gen.tsx index a13b0570ed1..bf672b5e238 100644 --- a/tests/gentype_tests/typescript-react-example/src/Core.gen.tsx +++ b/tests/gentype_tests/typescript-react-example/src/Core.gen.tsx @@ -47,9 +47,9 @@ export const promise0: (x:Promise) => Promise = CoreJS.promise0 export const promise1: (x:Promise) => Promise = CoreJS.promise1 as any; -export const taggedTemplate0: (x:unknown) => unknown = CoreJS.taggedTemplate0 as any; +export const taggedTemplate0: (x:((strings:TemplateStringsArray, ...values:string[]) => string)) => (strings:TemplateStringsArray, ...values:string[]) => string = CoreJS.taggedTemplate0 as any; -export const taggedTemplate1: (x:unknown) => unknown = CoreJS.taggedTemplate1 as any; +export const taggedTemplate1: (x:((strings:TemplateStringsArray, ...values:string[]) => string)) => (strings:TemplateStringsArray, ...values:string[]) => string = CoreJS.taggedTemplate1 as any; export const date0: (x:Date) => Date = CoreJS.date0 as any; From 023928feede081412ae07a46ba876db8a2c31cff Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 17:05:55 +0000 Subject: [PATCH 16/21] Link CHANGELOG entries to PR #8461 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b40d8f973..559d1204bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,13 @@ - Make `Jsx.component` abstract. https://github.com/rescript-lang/rescript/pull/8390 - Drop Node.js version 20.x support, as it is reaching EOL. https://github.com/rescript-lang/rescript/pull/8401 -- Remove the `@taggedTemplate` decorator in favor of the new first-class `taggedTemplate<'param, 'output>` builtin type. Using the decorator, or backtick tagged-template syntax on a value that is not a `taggedTemplate`, is now a compile error pointing to the new binding form. https://github.com/rescript-lang/rescript/issues/8415 +- Remove the `@taggedTemplate` decorator in favor of the new first-class `taggedTemplate<'param, 'output>` builtin type. Using the decorator, or backtick tagged-template syntax on a value that is not a `taggedTemplate`, is now a compile error pointing to the new binding form. https://github.com/rescript-lang/rescript/pull/8461 #### :eyeglasses: Spec Compliance #### :rocket: New Feature -- Add a first-class `taggedTemplate<'param, 'output>` builtin type and the `TaggedTemplate` stdlib module (`TaggedTemplate.make`). Tagged-template tags are now tracked through the type system, so they emit real JS tagged-template syntax across module boundaries, when passed as first-class values, and when constructed at runtime by a factory (e.g. `postgres`). https://github.com/rescript-lang/rescript/issues/8415 +- Add a first-class `taggedTemplate<'param, 'output>` builtin type and the `TaggedTemplate` stdlib module (`TaggedTemplate.make`). Tagged-template tags are now tracked through the type system, so they emit real JS tagged-template syntax across module boundaries, when passed as first-class values, and when constructed at runtime by a factory (e.g. `postgres`). https://github.com/rescript-lang/rescript/pull/8461 #### :bug: Bug fix From 08625643eab9bb636a66ab7104a5f56c08428001 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 17:21:51 +0000 Subject: [PATCH 17/21] Migrate vendored rescript-bun tags to taggedTemplate type The testrepo's rescript-bun dependency still used the removed @taggedTemplate decorator on its sh/shExpr shell tags, which now fails to compile. Extend the existing yarn patch to bind them with the first-class taggedTemplate<'param, 'output> type instead. --- .../rescript-bun-npm-2.1.0-d9adc91a04.patch | 15 ++++++++++++++- rewatch/testrepo/yarn.lock | 4 ++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/rewatch/testrepo/.yarn/patches/rescript-bun-npm-2.1.0-d9adc91a04.patch b/rewatch/testrepo/.yarn/patches/rescript-bun-npm-2.1.0-d9adc91a04.patch index d38b6b50913..ee5e4963794 100644 --- a/rewatch/testrepo/.yarn/patches/rescript-bun-npm-2.1.0-d9adc91a04.patch +++ b/rewatch/testrepo/.yarn/patches/rescript-bun-npm-2.1.0-d9adc91a04.patch @@ -68,7 +68,7 @@ index a71a6648ed36cb57849fc77a050117da8d6e438f..a253551552458d668d8ace7013f9ee16 +@send external values: t => IteratorObject.t = "entries" @send external forEach: (t, (string, string, t) => unit) => unit = "forEach" diff --git a/src/Globals.res b/src/Globals.res -index 8a19eb175c173d06fc19686f3f7171210960a6b8..cb2b967407f318e0783101e7e57c79db7717d978 100644 +index 8a19eb175c173d06fc19686f3f7171210960a6b8..d001c984dc2109ef89d2a8964a6e3856adbd2a93 100644 --- a/src/Globals.res +++ b/src/Globals.res @@ -145,9 +145,9 @@ module Headers = { @@ -137,6 +137,19 @@ index 8a19eb175c173d06fc19686f3f7171210960a6b8..cb2b967407f318e0783101e7e57c79db /** * Read from stdout as a string +@@ -2227,8 +2230,8 @@ module Shell = { + external escape: string => string = "escape" + } + +-@module("bun") @taggedTemplate +-external sh: (array, array) => ShellPromise.t = "$" ++@module("bun") ++external sh: taggedTemplate = "$" + +-@module("bun") @taggedTemplate +-external shExpr: (array, array) => ShellPromise.t = "$" ++@module("bun") ++external shExpr: taggedTemplate = "$" diff --git a/src/HTMLRewriter.res b/src/HTMLRewriter.res index 5260b585d144bd1a51074f5f3811501ba4efc47e..8e2cbdc5b5ab1e3516be81b56dd952bfc43c1a56 100644 --- a/src/HTMLRewriter.res diff --git a/rewatch/testrepo/yarn.lock b/rewatch/testrepo/yarn.lock index 3a9bc8450d7..8776749aac1 100644 --- a/rewatch/testrepo/yarn.lock +++ b/rewatch/testrepo/yarn.lock @@ -204,10 +204,10 @@ __metadata: "rescript-bun@patch:rescript-bun@npm%3A2.1.0#~/.yarn/patches/rescript-bun-npm-2.1.0-d9adc91a04.patch": version: 2.1.0 - resolution: "rescript-bun@patch:rescript-bun@npm%3A2.1.0#~/.yarn/patches/rescript-bun-npm-2.1.0-d9adc91a04.patch::version=2.1.0&hash=685f4a" + resolution: "rescript-bun@patch:rescript-bun@npm%3A2.1.0#~/.yarn/patches/rescript-bun-npm-2.1.0-d9adc91a04.patch::version=2.1.0&hash=05b3ce" peerDependencies: rescript: ">= 12.0.0-alpha.4" - checksum: 10c0/d7d8ee95a3e8fa66ffa63f17bd90d88c4ea9029246dcd9c0519787de5bbc1ca73b061cc1052cd6239e53e2dff3d2ae298fcef244f5e167b6cda35692bdd64337 + checksum: 10c0/bbc6b65d58a6bbfa556d915d2a43a76e8abbf2cae6d90ce8ffd1dad8d8d02f87b83936a8243393584667a6dd45ea8869150e4ef90b34ea63394fdc2e47ce3716 languageName: node linkType: hard From 12db98c08f2ef78a040a275f27b52a4ad46ff477 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 17:36:09 +0000 Subject: [PATCH 18/21] Drop unused predef type_tagged_template helper typecore builds the tagged-template type directly via Predef.path_tagged_template, so the type_tagged_template constructor helper was never called. Remove it (and its .mli signature) rather than leave dead, uncoverable code. --- compiler/ml/predef.ml | 3 --- compiler/ml/predef.mli | 1 - 2 files changed, 4 deletions(-) diff --git a/compiler/ml/predef.ml b/compiler/ml/predef.ml index 2525c1128d6..348cb1ce337 100644 --- a/compiler/ml/predef.ml +++ b/compiler/ml/predef.ml @@ -152,9 +152,6 @@ and type_unknown = newgenty (Tconstr (path_unkonwn, [], ref Mnil)) and type_extension_constructor = newgenty (Tconstr (path_extension_constructor, [], ref Mnil)) -and type_tagged_template t1 t2 = - newgenty (Tconstr (path_tagged_template, [t1; t2], ref Mnil)) - let ident_match_failure = ident_create_predef_exn "Match_failure" and ident_invalid_argument = ident_create_predef_exn "Invalid_argument" diff --git a/compiler/ml/predef.mli b/compiler/ml/predef.mli index a4112b024c4..802be290dee 100644 --- a/compiler/ml/predef.mli +++ b/compiler/ml/predef.mli @@ -31,7 +31,6 @@ val type_list : type_expr -> type_expr val type_option : type_expr -> type_expr val type_result : type_expr -> type_expr -> type_expr val type_dict : type_expr -> type_expr -val type_tagged_template : type_expr -> type_expr -> type_expr val type_bigint : type_expr val type_extension_constructor : type_expr From 111043dc7ff6754edc2e284bd16c26d8770a00fb Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Thu, 4 Jun 2026 17:38:44 +0000 Subject: [PATCH 19/21] Assert the tagged-template values-array invariant in typecore The parser always desugars the interpolated values of a tagged template into an array literal, so the non-array branch was dead code. Replace its silent generic-array fallback with assert false, matching the sibling sargs match in the same block and documenting the invariant instead of masking a potential desugaring bug. --- compiler/ml/typecore.ml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml index 2113a6b396d..7683c80acd9 100644 --- a/compiler/ml/typecore.ml +++ b/compiler/ml/typecore.ml @@ -2563,8 +2563,9 @@ and type_expect_ ?deprecated_context ~context ?in_function ?(recarg = Rejected) exp_attributes = values.pexp_attributes; exp_env = env; } - | _ -> - type_expect ~context:None env values (Predef.type_array param_ty) + (* The parser always desugars the interpolated values into an array + literal, so any other shape is a compiler invariant violation. *) + | _ -> assert false in ( [ (Asttypes.Nolabel, Some typed_strings); From 6b85aa7d4855d8f2f7ec69445d40b45f1e06d763 Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Fri, 5 Jun 2026 04:44:16 +0000 Subject: [PATCH 20/21] Drop dead error helper and exclude diagnostic tagged-template arms from coverage - Remove the unused context_to_string function (zero callers repo-wide); its TaggedTemplateValue arm was the only flagged line there. - Mark the debug-only lambda printers and the optimizer fast-path arms (no_side_effects, eq_primitive_approx) with [@coverage off] + a comment. These are reachable only from -drawlambda/-dlambda dumps or optimizer term-equality/purity checks that the test suite never exercises for tagged templates. The shared OR-pattern groups are kept intact so their existing coverage is unaffected. --- compiler/core/lam_analysis.ml | 8 ++++++-- compiler/core/lam_primitive.ml | 9 ++++++--- compiler/core/lam_print.ml | 3 ++- compiler/ml/error_message_utils.ml | 25 ------------------------- compiler/ml/printlambda.ml | 3 ++- 5 files changed, 16 insertions(+), 32 deletions(-) diff --git a/compiler/core/lam_analysis.ml b/compiler/core/lam_analysis.ml index 3d1c3d04bad..7b7d21614d1 100644 --- a/compiler/core/lam_analysis.ml +++ b/compiler/core/lam_analysis.ml @@ -91,8 +91,12 @@ let rec no_side_effects (lam : Lam.t) : bool = {code_info = Exp (Js_function _ | Js_literal _) | Stmt Js_stmt_comment} -> true - | Pjs_apply | Pjs_runtime_apply | Pjs_call _ | Ptagged_template | Pinit_mod - | Pupdate_mod | Pjs_unsafe_downgrade _ | Pdebugger | Pjs_fn_method + (* A tagged template invokes its tag at runtime, so it always has side + effects. Only reached here when all args are themselves pure, which the + test suite doesn't exercise. *) + | Ptagged_template -> false [@coverage off] + | Pjs_apply | Pjs_runtime_apply | Pjs_call _ | Pinit_mod | Pupdate_mod + | Pjs_unsafe_downgrade _ | Pdebugger | Pjs_fn_method (* Await promise *) | Pawait (* TODO *) diff --git a/compiler/core/lam_primitive.ml b/compiler/core/lam_primitive.ml index 4e3a53d3c07..974aff095b0 100644 --- a/compiler/core/lam_primitive.ml +++ b/compiler/core/lam_primitive.ml @@ -228,10 +228,13 @@ let eq_primitive_approx (lhs : t) (rhs : t) = | Pnull_to_opt | Pnull_undefined_to_opt | Pis_null | Pis_not_none | Psome | Psome_not_nest | Pis_undefined | Pis_null_undefined | Pimport | Ptypeof | Pfn_arity | Pis_poly_var_block | Pdebugger | Pinit_mod | Pupdate_mod - | Pduprecord | Ptagged_template | Pmakearray | Parraylength | Parrayrefu - | Parraysetu | Parrayrefs | Parraysets | Pjs_fn_make_unit | Pjs_fn_method - | Phash | Phash_mixstring | Phash_mixint | Phash_finalmix -> + | Pduprecord | Pmakearray | Parraylength | Parrayrefu | Parraysetu + | Parrayrefs | Parraysets | Pjs_fn_make_unit | Pjs_fn_method | Phash + | Phash_mixstring | Phash_mixint | Phash_finalmix -> rhs = lhs + (* Reachable only via the optimizer's term-equality comparison, which the + test suite doesn't exercise for tagged templates. *) + | Ptagged_template -> ( ((rhs = lhs) [@coverage off])) | Pcreate_extension a -> ( match rhs with | Pcreate_extension b -> a = (b : string) diff --git a/compiler/core/lam_print.ml b/compiler/core/lam_print.ml index 98127a471f8..9408b11aea4 100644 --- a/compiler/core/lam_print.ml +++ b/compiler/core/lam_print.ml @@ -51,7 +51,8 @@ let primitive ppf (prim : Lam_primitive.t) = | Pupdate_mod -> fprintf ppf "update_mod!" | Pjs_apply -> fprintf ppf "#apply" | Pjs_runtime_apply -> fprintf ppf "#runtime_apply" - | Ptagged_template -> fprintf ppf "#tagged_template" + (* Debug-only dump, exercised solely under -drawlambda/-dlambda. *) + | Ptagged_template -> fprintf ppf "#tagged_template" [@coverage off] | Pjs_unsafe_downgrade {name; setter} -> if setter then fprintf ppf "##%s#=" name else fprintf ppf "##%s" name | Pfn_arity -> fprintf ppf "fn.length" diff --git a/compiler/ml/error_message_utils.ml b/compiler/ml/error_message_utils.ml index fc7fe6373da..76867174493 100644 --- a/compiler/ml/error_message_utils.ml +++ b/compiler/ml/error_message_utils.ml @@ -114,31 +114,6 @@ type type_clash_context = | ForLoopCondition | Await -let context_to_string = function - | Some WhileCondition -> "WhileCondition" - | Some ForLoopCondition -> "ForLoopCondition" - | Some AssertCondition -> "AssertCondition" - | Some IfCondition -> "IfCondition" - | Some (Statement _) -> "Statement" - | Some (MathOperator _) -> "MathOperator" - | Some ArrayValue -> "ArrayValue" - | Some TaggedTemplateValue -> "TaggedTemplateValue" - | Some (SetRecordField _) -> "SetRecordField" - | Some (RecordField _) -> "RecordField" - | Some MaybeUnwrapOption -> "MaybeUnwrapOption" - | Some SwitchReturn -> "SwitchReturn" - | Some TryReturn -> "TryReturn" - | Some StringConcat -> "StringConcat" - | Some (FunctionArgument _) -> "FunctionArgument" - | Some JsxComponent -> "JsxComponent" - | Some ComparisonOperator -> "ComparisonOperator" - | Some IfReturn -> "IfReturn" - | Some TernaryReturn -> "TernaryReturn" - | Some Await -> "Await" - | Some BracedIdent -> "BracedIdent" - | Some LetUnwrapReturn -> "LetUnwrapReturn" - | None -> "None" - let fprintf = Format.fprintf let error_type_text ppf type_clash_context = diff --git a/compiler/ml/printlambda.ml b/compiler/ml/printlambda.ml index 47ca006b618..e30f3c867f2 100644 --- a/compiler/ml/printlambda.ml +++ b/compiler/ml/printlambda.ml @@ -263,7 +263,8 @@ let primitive ppf = function | Pjs_fn_make arity -> fprintf ppf "#fn_mk(%d)" arity | Pjs_fn_make_unit -> fprintf ppf "#fn_mk_unit" | Pjs_fn_method -> fprintf ppf "#fn_method" - | Ptagged_template -> fprintf ppf "#tagged_template" + (* Debug-only dump, exercised solely under -drawlambda/-dlambda. *) + | Ptagged_template -> fprintf ppf "#tagged_template" [@coverage off] let function_attribute ppf {inline; is_a_functor; return_unit} = if is_a_functor then fprintf ppf "is_a_functor@ "; From 9c03754cc86b11418a43478fe521e6217ced3a6d Mon Sep 17 00:00:00 2001 From: Jono Prest Date: Fri, 5 Jun 2026 09:11:07 +0000 Subject: [PATCH 21/21] Keep tagged-template purity arm in its natural group, not excluded The no_side_effects tagged-template branch was wrongly marked [@coverage off] with a comment implying it was unreachable. It is in fact reachable by an ordinary top-level tag application with pure arguments. Put Ptagged_template back alongside the other effectful primitives (Pjs_call, etc.); its bisect row is cold for the same reason theirs is (no_side_effects short-circuits on impure args), so it needs no special handling. --- compiler/core/lam_analysis.ml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/compiler/core/lam_analysis.ml b/compiler/core/lam_analysis.ml index 7b7d21614d1..29a8d3a1602 100644 --- a/compiler/core/lam_analysis.ml +++ b/compiler/core/lam_analysis.ml @@ -92,11 +92,9 @@ let rec no_side_effects (lam : Lam.t) : bool = -> true (* A tagged template invokes its tag at runtime, so it always has side - effects. Only reached here when all args are themselves pure, which the - test suite doesn't exercise. *) - | Ptagged_template -> false [@coverage off] - | Pjs_apply | Pjs_runtime_apply | Pjs_call _ | Pinit_mod | Pupdate_mod - | Pjs_unsafe_downgrade _ | Pdebugger | Pjs_fn_method + effects. *) + | Ptagged_template | Pjs_apply | Pjs_runtime_apply | Pjs_call _ | Pinit_mod + | Pupdate_mod | Pjs_unsafe_downgrade _ | Pdebugger | Pjs_fn_method (* Await promise *) | Pawait (* TODO *)