From 9960aa9c04cd26f8876fb2ea826b705714048f33 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Sun, 18 Oct 2026 07:51:09 +0000 Subject: [PATCH 1/2] Make `char` an alias of `int` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `char` was a private abbreviation of `int`: a char could be coerced to an int with `:>`, but an int could not be used as a char. It is now a plain alias, so char literals and ints mix freely, including in one `switch`. Pattern matching treats a char constant and an int constant with the same value as the same constant, for exhaustiveness, unused cases and switch compilation. Char literals keep their spelling in generated JavaScript comments and in warning examples. First step of #7028. Example (rescript): let f = x => switch x { | '\000' .. '\255' => 0 | 'ō' => 2 | _ => 3 } Before (js): function f(x) { if (x !== 333) { if (x >= 256) { return 3; } else { return 0; } } else { return 2; } } After (js): function f(x) { if (x >= 256) { if (x !== 333) { return 3; } else { return 2; } } else if (x >= 0) { return 0; } else { return 3; } } Source: tests/tests/src/string_unicode_test.mjs in the diff (the range starts at code point 0, written as a literal NUL character in the test). Review: Why keep `Const_char` instead of typing char literals as ints? It keeps the `/* 'a' */97` comments in the JavaScript and the char spelling in warnings such as "for example: | '\x00'". Review: Why do char switches now use int32 bounds instead of 0..max_int? A char can now hold any int, including negative ones, so a switch can no longer assume `x >= 0`. Two committed snapshots change for that reason. Review: Do integer range patterns such as `1 .. 5` work now? No, they still report `Invalid_interval`. Char ranges expand to one case per value, which does not scale to arbitrary ints; that needs interval patterns of their own. Area: typechecker lambda analysis tests Output: changed tests/tests/src/adt_optimize_test.mjs and string_unicode_test.mjs, char switches whose range starts at '\000' now send negative values to the default case Formatting: unchanged Formats: none PPX: unchanged Editor: pipe completion on a `char` value offers `Int` functions; it offered none before (tests/analysis_tests/tests/src/CompletionChar.res) Tests: +tests/tests/src/char_int_alias_test.res +tests/analysis_tests/tests/src/CompletionChar.res +tests/build_tests/super_errors/fixtures/warning_8_char_int_patterns.res +tests/build_tests/super_errors/fixtures/warning_11_equivalent_char_int_patterns.res ~tests/tests/src/adt_optimize_test.mjs ~tests/tests/src/string_unicode_test.mjs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JALXUyHhkDqzcB3WuW9Svo Signed-off-by: Christoph Knittel --- analysis/src/type_utils.ml | 6 +- changes/char-int-alias.feature.md | 1 + compiler/ml/matching.ml | 39 +++--- compiler/ml/parmatch.ml | 36 +++--- compiler/ml/parmatch.mli | 3 + compiler/ml/predef.ml | 5 +- .../tests/src/CompletionChar.res | 4 + .../tests/src/expected/CompletionChar.res.txt | 34 +++++ ..._equivalent_char_int_patterns.res.expected | 11 ++ .../warning_8_char_int_patterns.res.expected | 14 ++ ...arning_11_equivalent_char_int_patterns.res | 7 + .../fixtures/warning_8_char_int_patterns.res | 6 + tests/tests/src/adt_optimize_test.mjs | 5 +- tests/tests/src/char_int_alias_test.mjs | 121 ++++++++++++++++++ tests/tests/src/char_int_alias_test.res | 90 +++++++++++++ tests/tests/src/string_unicode_test.mjs | 10 +- 16 files changed, 342 insertions(+), 50 deletions(-) create mode 100644 changes/char-int-alias.feature.md create mode 100644 tests/analysis_tests/tests/src/CompletionChar.res create mode 100644 tests/analysis_tests/tests/src/expected/CompletionChar.res.txt create mode 100644 tests/build_tests/super_errors/expected/warning_11_equivalent_char_int_patterns.res.expected create mode 100644 tests/build_tests/super_errors/expected/warning_8_char_int_patterns.res.expected create mode 100644 tests/build_tests/super_errors/fixtures/warning_11_equivalent_char_int_patterns.res create mode 100644 tests/build_tests/super_errors/fixtures/warning_8_char_int_patterns.res create mode 100644 tests/tests/src/char_int_alias_test.mjs create mode 100644 tests/tests/src/char_int_alias_test.res diff --git a/analysis/src/type_utils.ml b/analysis/src/type_utils.ml index 681c8eab3e0..835d449ef59 100644 --- a/analysis/src/type_utils.ml +++ b/analysis/src/type_utils.ml @@ -1330,7 +1330,9 @@ let rec find_root_type_id ~full ~env ~state (t : Types.type_expr) = then Some (if debug then Printf.printf "[find_root_type_id] returning builtin\n"; - Path.name path) + (* [char] is an alias of [int] *) + if Path.same path Predef.path_char then Path.name Predef.path_int + else Path.name path) else None) | _ -> None @@ -1396,7 +1398,7 @@ let completion_path_from_maybe_builtin path = | Some ("list", _) -> Some ["Stdlib"; "List"] | Some ("result", _) -> Some ["Stdlib"; "Result"] | Some ("dict", _) -> Some ["Stdlib"; "Dict"] - | Some ("char", _) -> Some ["Stdlib"; "Char"] + | Some ("char", _) -> Some ["Stdlib"; "Int"] | _ -> ( match path |> Utils.expand_path |> List.rev with | [main_module; "t"] when String.starts_with ~prefix:"Stdlib_" main_module diff --git a/changes/char-int-alias.feature.md b/changes/char-int-alias.feature.md new file mode 100644 index 00000000000..02878598068 --- /dev/null +++ b/changes/char-int-alias.feature.md @@ -0,0 +1 @@ +- `char` is now an alias of `int`, so char literals and ints mix freely, including in `switch` patterns. diff --git a/compiler/ml/matching.ml b/compiler/ml/matching.ml index e96eecb02a7..0cdbb1b9fc1 100644 --- a/compiler/ml/matching.ml +++ b/compiler/ml/matching.ml @@ -1147,28 +1147,30 @@ let add make_matching_fun division eq_key key patl_action args = cell.pm.cases <- [patl_action]; (key, cell) :: division -(* Cells whose key is [hashable] are also found by hashing, which must agree - with [eq_key]. This keeps a column of many distinct keys linear. *) -let divide ?(hashable = fun _ -> false) make eq_key get_key get_args ctx pm = +(* Cells whose key has a [hash_key] are also found by hashing on it, which + must agree with [eq_key]. This keeps a column of many distinct keys + linear. *) +let divide ?(hash_key = fun _ -> None) make eq_key get_key get_args ctx pm = let cells = lazy (Hashtbl.create 16) in let rec divide_rec = function | (p :: patl, action) :: rem -> ( let this_match = divide_rec rem in let key = get_key p in let patl_action = (get_args p patl, action) in - if not (hashable key) then + match hash_key key with + | None -> add (make p pm.default ctx) this_match eq_key key patl_action pm.args - else + | Some hkey -> ( let cells = Lazy.force cells in - match Hashtbl.find_opt cells key with + match Hashtbl.find_opt cells hkey with | Some cell -> cell.pm.cases <- patl_action :: cell.pm.cases; this_match | None -> let cell = make p pm.default ctx pm.args in cell.pm.cases <- [patl_action]; - Hashtbl.add cells key cell; - (key, cell) :: this_match) + Hashtbl.add cells hkey cell; + (key, cell) :: this_match)) | _ -> [] in @@ -1226,9 +1228,10 @@ let make_constant_matching p def ctx = function let divide_constant ctx m = divide - ~hashable:(function - | Asttypes.Const_int _ | Const_char _ | Const_string _ -> true - | Const_float _ | Const_bigint _ -> false) + ~hash_key:(function + | (Asttypes.Const_int _ | Const_char _ | Const_string _) as c -> + Some (Parmatch.const_key c) + | Const_float _ | Const_bigint _ -> None) make_constant_matching (fun c d -> const_compare c d = 0) (get_key_constant "divide") @@ -1999,11 +2002,12 @@ let combine_constant loc arg cst partial ctx def let fail, local_jumps = mk_failaction_neg partial ctx def in let lambda1 = match (cst : Asttypes.constant) with - | Const_int _ -> + | Const_int _ | Const_char _ -> + (* [char] is an alias of [int], so a column can mix both. *) let int_lambda_list = List.map (function - | Asttypes.Const_int n, l -> (n, l) + | (Asttypes.Const_int n | Const_char n), l -> (n, l) | _ -> assert false) const_lambda_list in @@ -2011,15 +2015,6 @@ let combine_constant loc arg cst partial ctx def Otherwise the upper fail interval starts at 2147483648, which wraps when converted to a Lambda integer constant. *) call_switcher fail arg int32_low int32_high int_lambda_list - | Const_char _ -> - let int_lambda_list = - List.map - (function - | Asttypes.Const_char c, l -> (c, l) - | _ -> assert false) - const_lambda_list - in - call_switcher fail arg 0 max_int int_lambda_list | Const_string _ -> (* Note as the bytecode compiler may resort to dichotomic search, the clauses of stringswitch are sorted with duplicates removed. diff --git a/compiler/ml/parmatch.ml b/compiler/ml/parmatch.ml index 3e731ed345c..643be60632f 100644 --- a/compiler/ml/parmatch.ml +++ b/compiler/ml/parmatch.ml @@ -155,8 +155,7 @@ let all_coherent column = | _ -> false) | Tpat_constant c1, Tpat_constant c2 -> ( match (c1, c2) with - | Const_char _, Const_char _ - | Const_int _, Const_int _ + | (Const_char _ | Const_int _), (Const_char _ | Const_int _) | Const_bigint _, Const_bigint _ | Const_float _, Const_float _ | Const_string _, Const_string _ -> @@ -270,8 +269,16 @@ let const_compare x y = | Const_bigint (s1, b1), Const_bigint (s2, b2) -> Bigint_utils.compare (s1, b1) (s2, b2) | Const_string s1, Const_string s2 -> String.compare s1 s2 + | (Const_char i1 | Const_int i1), (Const_char i2 | Const_int i2) -> + compare i1 i2 | _, _ -> compare x y +(* [char] is an alias of [int], so a char constant and an int constant with the + same value match the same values. Hash tables of constants use this key. *) +let const_key = function + | Const_char i -> Const_int i + | c -> c + let records_args l1 l2 = (* Invariant: fields are already sorted by Typecore.type_record_elem_list *) let rec combine r1 r2 l1 l2 = @@ -765,7 +772,7 @@ let filter_all pat0 pss = let insert q qs = match q.pat_desc with | Tpat_constant ((Const_int _ | Const_char _ | Const_string _) as c) -> - find_or_add const_groups c q qs + find_or_add const_groups (const_key c) q qs | Tpat_variant (l, _, _) -> find_or_add variant_groups l q qs | _ -> ( match List.find_opt (fun (q0, _) -> simple_match q0 q) !groups with @@ -1058,21 +1065,18 @@ let build_other ext env : Typedtree.pattern = (fun p_res pat -> make_pat (Tpat_or (pat, p_res, None)) p.pat_type p.pat_env) pat other_pats) - | (({pat_desc = Tpat_constant (Const_int _)} as p), _) :: _ -> - build_other_constant - (function - | Tpat_constant (Const_int i) -> i - | _ -> assert false) - (function - | i -> Tpat_constant (Const_int i)) - 0 succ p env - | (({pat_desc = Tpat_constant (Const_char _)} as p), _) :: _ -> + | (({pat_desc = Tpat_constant ((Const_int _ | Const_char _) as c)} as p), _) + :: _ -> + (* Int and char constants can share a column; the example takes the kind + of the first one. *) build_other_constant (function - | Tpat_constant (Const_char i) -> i + | Tpat_constant (Const_int i | Const_char i) -> i | _ -> assert false) - (function - | i -> Tpat_constant (Const_char i)) + (fun i -> + match c with + | Const_char _ -> Tpat_constant (Const_char i) + | _ -> Tpat_constant (Const_int i)) 0 succ p env | (({pat_desc = Tpat_constant (Const_bigint _)} as p), _) :: _ -> build_other_constant @@ -1713,7 +1717,7 @@ let get_mins_pats pss = Tpat_constant ((Const_int _ | Const_char _ | Const_string _) as c); } :: _ -> - Some c + Some (const_key c) | _ -> None in (* [select_rec [] pss] of [get_mins le_pats]: drops each row covered by a diff --git a/compiler/ml/parmatch.mli b/compiler/ml/parmatch.mli index 1c3c50bfd5a..0ae793005b2 100644 --- a/compiler/ml/parmatch.mli +++ b/compiler/ml/parmatch.mli @@ -34,6 +34,9 @@ val all_record_args : (Longident.t loc * label_description * pattern * bool) list val const_compare : constant -> constant -> int +(* A hash key agreeing with [const_compare]: char constants map to ints. *) +val const_key : constant -> constant + val le_pat : pattern -> pattern -> bool val le_pats : pattern list -> pattern list -> bool diff --git a/compiler/ml/predef.ml b/compiler/ml/predef.ml index 531625ff3e2..4ca95022843 100644 --- a/compiler/ml/predef.ml +++ b/compiler/ml/predef.ml @@ -434,9 +434,8 @@ let common_initial_env add_type add_extension empty_env = let build_initial_env add_type add_exception empty_env = let common = common_initial_env add_type add_exception empty_env in - let decl_type_char = - {decl_abstr with type_manifest = Some (type_int ()); type_private = Private} - in + (* [char] is an alias of [int]: a char literal is its code point. *) + let decl_type_char = {decl_abstr with type_manifest = Some (type_int ())} in add_type ident_char decl_type_char common let () = diff --git a/tests/analysis_tests/tests/src/CompletionChar.res b/tests/analysis_tests/tests/src/CompletionChar.res new file mode 100644 index 00000000000..67675d5367b --- /dev/null +++ b/tests/analysis_tests/tests/src/CompletionChar.res @@ -0,0 +1,4 @@ +// `char` is an alias of `int`, so a char completes `Int` functions. +let c = 'a' +// let _ = c->toS +// ^com diff --git a/tests/analysis_tests/tests/src/expected/CompletionChar.res.txt b/tests/analysis_tests/tests/src/expected/CompletionChar.res.txt new file mode 100644 index 00000000000..94b273aa561 --- /dev/null +++ b/tests/analysis_tests/tests/src/expected/CompletionChar.res.txt @@ -0,0 +1,34 @@ +Complete src/CompletionChar.res 2:17 +pos_cursor:[2:17] pos_no_white:[2:16] Found expr:[2:11->2:17] +Completable: Cpath Value[c]->toS +Package opens Stdlib.place holder Pervasives.JsxModules.place holder +Resolved opens 1 Stdlib +ContextPath Value[c]->toS +ContextPath Value[c] +Path c +Path Stdlib.Int.toS +Path toS +[ + { + "deprecated": true, + "detail": "(int, ~radix: int) => string", + "documentation": { + "kind": "markdown", + "value": "Deprecated: Use `toString` instead\n\n\n`toStringWithRadix(n, ~radix)` return a `string` representing the given value.\n`~radix` specifies the radix base to use for the formatted number.\nSee [`Number.toString`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString)\non MDN.\n\n## Examples\n\n```rescript\nInt.toStringWithRadix(6, ~radix=2) // \"110\"\nInt.toStringWithRadix(373592855, ~radix=16) // \"16449317\"\nInt.toStringWithRadix(123456, ~radix=36) // \"2n9c\"\n```\n\n## Exceptions\n\n`RangeError`: if `radix` is less than 2 or greater than 36.\n" + }, + "kind": 12, + "label": "Int.toStringWithRadix", + "tags": [ 1 ] + }, + { + "detail": "(int, ~radix: int=?) => string", + "documentation": { + "kind": "markdown", + "value": "\n`toString(n, ~radix=?)` return a `string` representing the given value.\n`~radix` specifies the radix base to use for the formatted number.\nSee [`Number.toString`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString)\non MDN.\n\n## Examples\n\n```rescript\nInt.toString(1000) // \"1000\"\nInt.toString(-1000) // \"-1000\"\nInt.toString(6, ~radix=2) // \"110\"\nInt.toString(373592855, ~radix=16) // \"16449317\"\nInt.toString(123456, ~radix=36) // \"2n9c\"\n```\n\n## Exceptions\n\n`RangeError`: if `radix` is less than 2 or greater than 36.\n" + }, + "kind": 12, + "label": "Int.toString", + "tags": [] + } +] + diff --git a/tests/build_tests/super_errors/expected/warning_11_equivalent_char_int_patterns.res.expected b/tests/build_tests/super_errors/expected/warning_11_equivalent_char_int_patterns.res.expected new file mode 100644 index 00000000000..321987bb9d3 --- /dev/null +++ b/tests/build_tests/super_errors/expected/warning_11_equivalent_char_int_patterns.res.expected @@ -0,0 +1,11 @@ + + Warning number 11 + /.../fixtures/warning_11_equivalent_char_int_patterns.res:5:5-6 + + 3 ┆ switch x { + 4 ┆ | 'a' => 1 + 5 ┆ | 97 => 2 + 6 ┆ | _ => 3 + 7 ┆ } + + this match case is unused. \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/warning_8_char_int_patterns.res.expected b/tests/build_tests/super_errors/expected/warning_8_char_int_patterns.res.expected new file mode 100644 index 00000000000..838e3d47fc0 --- /dev/null +++ b/tests/build_tests/super_errors/expected/warning_8_char_int_patterns.res.expected @@ -0,0 +1,14 @@ + + Warning number 8 + /.../fixtures/warning_8_char_int_patterns.res:3:3-6:3 + + 1 │ // Char and int patterns share one column when checking exhaustiveness. + 2 │ let f = x => + 3 │ switch x { + 4 │  | 'a' => 1 + 5 │  | 98 => 2 + 6 │  } + 7 │ + + You forgot to handle a possible case here, for example: + | '\x00' \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/warning_11_equivalent_char_int_patterns.res b/tests/build_tests/super_errors/fixtures/warning_11_equivalent_char_int_patterns.res new file mode 100644 index 00000000000..c0dcd02334c --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/warning_11_equivalent_char_int_patterns.res @@ -0,0 +1,7 @@ +// `char` is an alias of `int`, so `97` repeats `'a'`. +let f = x => + switch x { + | 'a' => 1 + | 97 => 2 + | _ => 3 + } diff --git a/tests/build_tests/super_errors/fixtures/warning_8_char_int_patterns.res b/tests/build_tests/super_errors/fixtures/warning_8_char_int_patterns.res new file mode 100644 index 00000000000..501e7a1ce0c --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/warning_8_char_int_patterns.res @@ -0,0 +1,6 @@ +// Char and int patterns share one column when checking exhaustiveness. +let f = x => + switch x { + | 'a' => 1 + | 98 => 2 + } diff --git a/tests/tests/src/adt_optimize_test.mjs b/tests/tests/src/adt_optimize_test.mjs index fe8b267ba01..d489fb71c51 100644 --- a/tests/tests/src/adt_optimize_test.mjs +++ b/tests/tests/src/adt_optimize_test.mjs @@ -24,9 +24,6 @@ function f_0(x) { } function f2(x) { - if (x >= 3) { - return "T003"; - } switch (x) { case 0 : return "T000"; @@ -34,6 +31,8 @@ function f2(x) { return "T001"; case 2 : return "T002"; + default: + return "T003"; } } diff --git a/tests/tests/src/char_int_alias_test.mjs b/tests/tests/src/char_int_alias_test.mjs new file mode 100644 index 00000000000..f25916d26b1 --- /dev/null +++ b/tests/tests/src/char_int_alias_test.mjs @@ -0,0 +1,121 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Mocha from "mocha"; +import * as Test_utils from "./test_utils.mjs"; + +let toInt = 98; + +function mixed(x) { + switch (x) { + case 97 : + return "a"; + case 98 : + return "b"; + case 99 : + case 100 : + case 101 : + return "c-e"; + case 102 : + case 103 : + return "f-g"; + default: + return "other"; + } +} + +function isDigitAt(s, i) { + let match = s.codePointAt(i); + if (match !== undefined) { + return !(match > 57 || match < 48); + } else { + return false; + } +} + +function emoji(x) { + if (x >= 128076) { + if (x >= 128513) { + if (x >= 128592) { + return "other"; + } else { + return "face"; + } + } else if (x >= 128512) { + return "grinning"; + } else { + return "other"; + } + } else if (x !== 55357) { + if (x >= 128075) { + return "wave"; + } else { + return "other"; + } + } else { + return "high surrogate"; + } +} + +function emojiAt(s, i) { + let c = s.codePointAt(i); + if (c !== undefined) { + return emoji(c); + } else { + return "none"; + } +} + +function isControl(c) { + return !(c > 31 || c < 0); +} + +Mocha.describe("Char_int_alias_test", () => { + Mocha.test("conversions", () => { + Test_utils.eq("File \"char_int_alias_test.res\", line 50, characters 7-14", 65, /* 'A' */65); + Test_utils.eq("File \"char_int_alias_test.res\", line 51, characters 7-14", toInt, 98); + }); + Mocha.test("mixed patterns", () => { + Test_utils.eq("File \"char_int_alias_test.res\", line 55, characters 7-14", "a", "a"); + Test_utils.eq("File \"char_int_alias_test.res\", line 56, characters 7-14", mixed(/* 'b' */98), "b"); + Test_utils.eq("File \"char_int_alias_test.res\", line 57, characters 7-14", mixed(/* 'd' */100), "c-e"); + Test_utils.eq("File \"char_int_alias_test.res\", line 58, characters 7-14", mixed(/* 'f' */102), "f-g"); + Test_utils.eq("File \"char_int_alias_test.res\", line 59, characters 7-14", "f-g", "f-g"); + Test_utils.eq("File \"char_int_alias_test.res\", line 60, characters 7-14", "other", "other"); + Test_utils.eq("File \"char_int_alias_test.res\", line 61, characters 7-14", "other", "other"); + }); + Mocha.test("code points", () => { + Test_utils.eq("File \"char_int_alias_test.res\", line 65, characters 7-14", isDigitAt("a1", 0), false); + Test_utils.eq("File \"char_int_alias_test.res\", line 66, characters 7-14", isDigitAt("a1", 1), true); + Test_utils.eq("File \"char_int_alias_test.res\", line 67, characters 7-14", isDigitAt("a1", 2), false); + }); + Mocha.test("emoji", () => { + Test_utils.eq("File \"char_int_alias_test.res\", line 71, characters 7-14", /* '😀' */128512, 128512); + Test_utils.eq("File \"char_int_alias_test.res\", line 72, characters 7-14", "😀".codePointAt(0), /* '😀' */128512); + Test_utils.eq("File \"char_int_alias_test.res\", line 73, characters 7-14", emoji(/* '😀' */128512), "grinning"); + Test_utils.eq("File \"char_int_alias_test.res\", line 74, characters 7-14", emoji(/* '😂' */128514), "face"); + Test_utils.eq("File \"char_int_alias_test.res\", line 75, characters 7-14", emoji(/* '👋' */128075), "wave"); + Test_utils.eq("File \"char_int_alias_test.res\", line 76, characters 7-14", emoji(/* '🚀' */128640), "other"); + Test_utils.eq("File \"char_int_alias_test.res\", line 77, characters 7-14", emojiAt("a😀", 1), "grinning"); + Test_utils.eq("File \"char_int_alias_test.res\", line 80, characters 7-14", emojiAt("a😀", 2), "other"); + Test_utils.eq("File \"char_int_alias_test.res\", line 81, characters 7-14", emojiAt(String.fromCharCode(55357), 0), "high surrogate"); + Test_utils.eq("File \"char_int_alias_test.res\", line 82, characters 7-14", emojiAt("a😀", 3), "none"); + }); + Mocha.test("negative values", () => { + Test_utils.eq("File \"char_int_alias_test.res\", line 86, characters 7-14", true, true); + Test_utils.eq("File \"char_int_alias_test.res\", line 87, characters 7-14", false, false); + Test_utils.eq("File \"char_int_alias_test.res\", line 88, characters 7-14", false, false); + }); +}); + +let fromInt = 65; + +export { + fromInt, + toInt, + mixed, + isDigitAt, + emoji, + emojiAt, + isControl, +} +/* Not a pure module */ diff --git a/tests/tests/src/char_int_alias_test.res b/tests/tests/src/char_int_alias_test.res new file mode 100644 index 00000000000..c7c1d7454f7 --- /dev/null +++ b/tests/tests/src/char_int_alias_test.res @@ -0,0 +1,90 @@ +open Mocha +open Test_utils + +// `char` is an alias of `int`, so char literals and ints mix freely. +let fromInt: char = 65 +let toInt: int = 'a' + 1 + +let mixed = x => + switch x { + | 'a' => "a" + | 98 => "b" + | 'c' .. 'e' => "c-e" + | 102 | 'g' => "f-g" + | _ => "other" + } + +// A char range matches the int result of `codePointAt` directly. +let isDigitAt = (s, i) => + switch String.codePointAt(s, i) { + | Some('0' .. '9') => true + | Some(_) | None => false + } + +// Emoji are outside the BMP: a literal is the full code point, as returned by +// `codePointAt`, not a UTF-16 surrogate. +let emoji = x => + switch x { + | '😀' => "grinning" + | '\u{1F601}' .. '\u{1F64F}' => "face" + | 0x1F44B => "wave" + | 0xD83D => "high surrogate" + | _ => "other" + } + +let emojiAt = (s, i) => + switch String.codePointAt(s, i) { + | Some(c) => emoji(c) + | None => "none" + } + +// A char can now hold any int, so the lower bound must be checked. +let isControl = (c: char) => + switch c { + | '\000' .. '\031' => true + | _ => false + } + +describe(__MODULE__, () => { + test("conversions", () => { + eq(__LOC__, fromInt, 'A') + eq(__LOC__, toInt, 98) + }) + + test("mixed patterns", () => { + eq(__LOC__, mixed(97), "a") + eq(__LOC__, mixed('b'), "b") + eq(__LOC__, mixed('d'), "c-e") + eq(__LOC__, mixed('f'), "f-g") + eq(__LOC__, mixed(103), "f-g") + eq(__LOC__, mixed(104), "other") + eq(__LOC__, mixed(-97), "other") + }) + + test("code points", () => { + eq(__LOC__, isDigitAt("a1", 0), false) + eq(__LOC__, isDigitAt("a1", 1), true) + eq(__LOC__, isDigitAt("a1", 2), false) + }) + + test("emoji", () => { + eq(__LOC__, '😀', 0x1F600) + eq(__LOC__, String.codePointAt("😀", 0), Some('😀')) + eq(__LOC__, emoji('😀'), "grinning") + eq(__LOC__, emoji('😂'), "face") + eq(__LOC__, emoji('👋'), "wave") + eq(__LOC__, emoji('🚀'), "other") + eq(__LOC__, emojiAt("a😀", 1), "grinning") + // Index 2 is the low surrogate of 😀. A lone high surrogate is its own + // code point. + eq(__LOC__, emojiAt("a😀", 2), "other") + eq(__LOC__, emojiAt(String.fromCharCode(0xD83D), 0), "high surrogate") + eq(__LOC__, emojiAt("a😀", 3), "none") + }) + + test("negative values", () => { + eq(__LOC__, isControl('\n'), true) + eq(__LOC__, isControl(' '), false) + eq(__LOC__, isControl(-1), false) + }) +}) diff --git a/tests/tests/src/string_unicode_test.mjs b/tests/tests/src/string_unicode_test.mjs index c0fc6c432dc..969ad279759 100644 --- a/tests/tests/src/string_unicode_test.mjs +++ b/tests/tests/src/string_unicode_test.mjs @@ -10,14 +10,16 @@ console.log(`你好`); console.log("你好"); function f(x) { - if (x !== 333) { - if (x >= 256) { + if (x >= 256) { + if (x !== 333) { return 3; } else { - return 0; + return 2; } + } else if (x >= 0) { + return 0; } else { - return 2; + return 3; } } From eaf8b90be1a9add9aa0e47749bebb207a2f8a1f9 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Sun, 18 Oct 2026 12:29:22 +0000 Subject: [PATCH 2/2] Type the runtime's code point lookup as int `Primitive_string_extern.getChar` returned `option`; it returns `option` now that `char` is an alias of `int`, which leaves no use of `char` in the runtime. The generated JavaScript is unchanged. Area: runtime Output: unchanged Tests: none, because the type is an alias of the old one and the output is unchanged Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JALXUyHhkDqzcB3WuW9Svo Signed-off-by: Christoph Knittel --- packages/@rescript/runtime/Primitive_string_extern.res | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@rescript/runtime/Primitive_string_extern.res b/packages/@rescript/runtime/Primitive_string_extern.res index f35e9f49fdd..fcddf8187ec 100644 --- a/packages/@rescript/runtime/Primitive_string_extern.res +++ b/packages/@rescript/runtime/Primitive_string_extern.res @@ -6,4 +6,4 @@ external length: string => int = "%string_length" -@send external getChar: (string, int) => option = "codePointAt" +@send external getChar: (string, int) => option = "codePointAt"