From 46765239763d60454e38b68403e5a9dc86c1780e Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 30 Oct 2026 16:14:26 +0000 Subject: [PATCH] Print @as names and external strings as ReScript string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: The printer wrote the @as name of a constructor with OCaml's %S and the @as name of a field and the strings of an external with String.escaped. Both turn non-ASCII bytes into decimal escapes such as \195\169, which ReScript reads back as other characters: an interface written by createInterface for an applied functor then gave its fields and constructors other runtime names than the implementation (#9142). They are now encoded with String_literal.encode_js_string, which keeps valid UTF-8 and escapes only what a string literal needs. Diagnostic modified (@as names of a constructor and a field): Example: module M: { type t = | @as("é") A({@as("prénom") name: string}) | @as("a\"b") B } = { type t = | @as("é") A({@as("prénom") name: int}) | @as("a\"b") B } Before (diagnostic): Actual declaration (as_names_printed_as_written.res:6:3-66): type t = @as("\195\169") A({@as("pr\195\169nom") name: int}) | @as("a\"b") B After (diagnostic): Actual declaration (as_names_printed_as_written.res:6:3-66): type t = @as("é") A({@as("prénom") name: int}) | @as("a\"b") B Diagnostic modified (strings of an external): Example: module M: { @module("ünïcode") @scope("ëxtra") external f: int => int = "f" } = { @module("ünïcode") @scope("ëxtra") external f: string => int = "f" } Before (diagnostic): Actual declaration (external_strings_printed_as_written.res:6:3-7:33): @val @module("\195\188n\195\175code") @scope("\195\171xtra") external f: string => int = "f" After (diagnostic): Actual declaration (external_strings_printed_as_written.res:6:3-7:33): @val @module("ünïcode") @scope("ëxtra") external f: string => int = "f" Format modified (createInterface for an applied functor): Example: module Make = (X: {type t}) => { type record = {@as("prénom") name: X.t} } module Applied = Make({ type t = string }) Before (rescript): the field is named "prénom" when the interface is read back module Applied: { type record = {@as("pr\195\169nom") name: string} } After (rescript): module Applied: { type record = {@as("prénom") name: string} } Fixes: #9142 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gLpf7RN1MiRcubPgRn Signed-off-by: Christoph Knittel --- changes/printed-string-escaping.fix.md | 1 + compiler/ml/printtyp.ml | 3 ++- compiler/syntax/src/res_outcome_printer.ml | 11 ++++++++-- .../as_names_printed_as_written.res.expected | 21 +++++++++++++++++++ ...al_strings_printed_as_written.res.expected | 19 +++++++++++++++++ .../fixtures/as_names_printed_as_written.res | 7 +++++++ .../external_strings_printed_as_written.res | 8 +++++++ 7 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 changes/printed-string-escaping.fix.md create mode 100644 tests/build_tests/super_errors/expected/as_names_printed_as_written.res.expected create mode 100644 tests/build_tests/super_errors/expected/external_strings_printed_as_written.res.expected create mode 100644 tests/build_tests/super_errors/fixtures/as_names_printed_as_written.res create mode 100644 tests/build_tests/super_errors/fixtures/external_strings_printed_as_written.res diff --git a/changes/printed-string-escaping.fix.md b/changes/printed-string-escaping.fix.md new file mode 100644 index 0000000000..09199f169f --- /dev/null +++ b/changes/printed-string-escaping.fix.md @@ -0,0 +1 @@ +- Print the `@as` names of fields and constructors and the strings of externals as they are written, so that non-ASCII letters are no longer turned into escapes that read back as other characters in error messages, hover and generated interfaces. diff --git a/compiler/ml/printtyp.ml b/compiler/ml/printtyp.ml index c9a19c4a15..8d3b29e866 100644 --- a/compiler/ml/printtyp.ml +++ b/compiler/ml/printtyp.ml @@ -945,7 +945,8 @@ and tree_of_constructor ?printing_context ~layout ~position cd = let as_annotation = function | Variant_runtime.Null -> "@as(null)" | Undefined -> "@as(undefined)" - | String s -> Printf.sprintf "@as(%S)" s + | String s -> + Printf.sprintf "@as(\"%s\")" (String_literal.encode_js_string s) | Int i -> Printf.sprintf "@as(%d)" i | Float f -> Printf.sprintf "@as(%s)" f | Bool b -> Printf.sprintf "@as(%b)" b diff --git a/compiler/syntax/src/res_outcome_printer.ml b/compiler/syntax/src/res_outcome_printer.ml index 831466e4d9..a473919c62 100644 --- a/compiler/syntax/src/res_outcome_printer.ml +++ b/compiler/syntax/src/res_outcome_printer.ml @@ -41,6 +41,9 @@ let collect_arrow_args (out_type : Outcometree.out_type) = | Otyp_arrow (args, ret) -> (args, ret) | _ -> ([], out_type) +let print_string_literal_doc s = + Doc.text ("\"" ^ String_literal.encode_js_string s ^ "\"") + let rec collect_functor_args (out_module_type : Outcometree.out_module_type) args = match out_module_type with @@ -398,7 +401,12 @@ and print_record_decl_row_doc (name, runtime_name, mut, opt, arg) = [ (match runtime_name with | Some runtime_name -> - Doc.text ("@as(\"" ^ String.escaped runtime_name ^ "\") ") + Doc.concat + [ + Doc.text "@as("; + print_string_literal_doc runtime_name; + Doc.text ") "; + ] | None -> Doc.nil); (if mut then Doc.text "mutable " else Doc.nil); Printer.print_ident_like ~allow_uident:false name; @@ -454,7 +462,6 @@ let print_type_parameter_doc (typ, (co, cn)) = (* Print a digested FFI declaration as the surface attributes the user wrote; the stored form is the declaration itself. *) -let print_string_literal_doc s = Doc.text ("\"" ^ String.escaped s ^ "\"") let print_inline_const_doc (c : External_ffi_types.inline_const) = match c with diff --git a/tests/build_tests/super_errors/expected/as_names_printed_as_written.res.expected b/tests/build_tests/super_errors/expected/as_names_printed_as_written.res.expected new file mode 100644 index 0000000000..6e4805b907 --- /dev/null +++ b/tests/build_tests/super_errors/expected/as_names_printed_as_written.res.expected @@ -0,0 +1,21 @@ + + We've found a bug for you! + /.../fixtures/as_names_printed_as_written.res:5:5-7:1 + + 3 │ module M: { + 4 │ type t = | @as("é") A({@as("prénom") name: string}) | @as("a\"b") B + 5 │ } = { + 6 │  type t = | @as("é") A({@as("prénom") name: int}) | @as("a\"b") B + 7 │ } + 8 │ + + Signature mismatch: + + Actual declaration (/.../fixtures/as_names_printed_as_written.res:6:3-66): + type t = @as("é") A({@as("prénom") name: int}) | @as("a\"b") B + + Expected declaration (/.../fixtures/as_names_printed_as_written.res:4:3-69): + type t = @as("é") A({@as("prénom") name: string}) | @as("a\"b") B + + The records of constructor A differ: + The types for field name are not equal. \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/external_strings_printed_as_written.res.expected b/tests/build_tests/super_errors/expected/external_strings_printed_as_written.res.expected new file mode 100644 index 0000000000..4a733fe269 --- /dev/null +++ b/tests/build_tests/super_errors/expected/external_strings_printed_as_written.res.expected @@ -0,0 +1,19 @@ + + We've found a bug for you! + /.../fixtures/external_strings_printed_as_written.res:5:5-8:1 + + 3 │ @module("ünïcode") @scope("ëxtra") + 4 │ external f: int => int = "f" + 5 │ } = { + 6 │  @module("ünïcode") @scope("ëxtra") + 7 │  external f: string => int = "f" + 8 │ } + 9 │ + + Signature mismatch: + + Actual declaration (/.../fixtures/external_strings_printed_as_written.res:6:3-7:33): + @val @module("ünïcode") @scope("ëxtra") external f: string => int = "f" + + Expected declaration (/.../fixtures/external_strings_printed_as_written.res:3:3-4:30): + @val @module("ünïcode") @scope("ëxtra") external f: int => int = "f" \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/as_names_printed_as_written.res b/tests/build_tests/super_errors/fixtures/as_names_printed_as_written.res new file mode 100644 index 0000000000..f0535b9597 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/as_names_printed_as_written.res @@ -0,0 +1,7 @@ +// The @as names of constructors and fields print as they are written in +// source, non-ASCII letters and quotes included +module M: { + type t = | @as("é") A({@as("prénom") name: string}) | @as("a\"b") B +} = { + type t = | @as("é") A({@as("prénom") name: int}) | @as("a\"b") B +} diff --git a/tests/build_tests/super_errors/fixtures/external_strings_printed_as_written.res b/tests/build_tests/super_errors/fixtures/external_strings_printed_as_written.res new file mode 100644 index 0000000000..b0d75b8066 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/external_strings_printed_as_written.res @@ -0,0 +1,8 @@ +// The strings of a printed external print as they are written in source +module M: { + @module("ünïcode") @scope("ëxtra") + external f: int => int = "f" +} = { + @module("ünïcode") @scope("ëxtra") + external f: string => int = "f" +}