From 24a773a08e4b60277a53693b46aed8705e16261a Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Sun, 18 Oct 2026 17:52:30 +0000 Subject: [PATCH] Support int range patterns `switch` now accepts int ranges such as `200 .. 299`, and bounds that mix ints and chars, since `char` is an alias of `int`. They use the `Tpat_interval` node char ranges compile to, so a range costs the same however wide it is. Ranges of other constants, such as `1.0 .. 5.0`, still report `Invalid_interval`, whose message now names int and char intervals. Int switches are never exhaustive, as before: a missing-case example is the least uncovered value from 0 up, else the greatest one below 0. Example (rescript): let status = code => switch code { | 200 .. 299 => "success" | 301 | 302 | 307 | 308 => "redirect" | 400 .. 499 => "client error" | 500 .. 599 => "server error" | _ => "unknown" } After (js): function status(code) { if (code >= 309) { if (code >= 500) { if (code >= 600) { return "unknown"; } else { return "server error"; } } else if (code >= 400) { return "client error"; } else { return "unknown"; } } if (code < 200) { return "unknown"; } if (code < 300) { return "success"; } switch (code) { case 300 : case 303 : case 304 : case 305 : case 306 : return "unknown"; case 301 : case 302 : case 307 : case 308 : return "redirect"; } } Source: tests/tests/src/int_range_test.mjs in the diff. Before this commit the example reported "Only character intervals are supported in patterns." Review: Does a switch covering all ints count as exhaustive? No. Ints are never matched exhaustively, so warning 8 still reports `_` for a switch on `-0x80000000 .. -1 | 0 .. 0x7FFFFFFF`, as it does for constants. Review: Can a mixed range give an invalid example? Codex found that a range from a char to an int covering all non-negative ints suggested a char for -1. Examples are written as chars only when they are valid code points, a fix in the parent commit; warning_8_mixed_range_example.res covers this case. Review: How was it checked? A fuzzer generated 600 switches over int, char and mixed ranges with negative bounds, or-patterns, guards, tuples and options; the results equal a reference evaluation for every input. Three programs hit `Fatal error: Matching.comp_exit`; each reduces to `| _ => ... | p1 | p2 if g() => ...`, which crashes the parent commits the same way with char patterns. Area: typechecker tests Output: unchanged, existing programs compile the same; int ranges are new Formatting: unchanged Formats: none PPX: unchanged Editor: unchanged Tests: +tests/tests/src/int_range_test.res +tests/build_tests/super_errors/fixtures/warning_8_int_range_example.res +tests/build_tests/super_errors/fixtures/warning_8_mixed_range_example.res +tests/build_tests/super_errors/fixtures/pattern_interval_float.res -tests/build_tests/super_errors/fixtures/pattern_interval_non_char.res Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JALXUyHhkDqzcB3WuW9Svo Signed-off-by: Christoph Knittel --- changes/int-range-patterns.feature.md | 1 + compiler/ml/parmatch.ml | 31 ++-- compiler/ml/typecore.ml | 44 +++-- .../pattern_interval_float.res.expected | 11 ++ .../pattern_interval_non_char.res.expected | 11 -- .../warning_8_int_range_example.res.expected | 14 ++ ...warning_8_mixed_range_example.res.expected | 13 ++ ...on_char.res => pattern_interval_float.res} | 2 +- .../fixtures/warning_8_int_range_example.res | 6 + .../warning_8_mixed_range_example.res | 6 + tests/tests/src/int_range_test.mjs | 155 ++++++++++++++++++ tests/tests/src/int_range_test.res | 87 ++++++++++ 12 files changed, 345 insertions(+), 36 deletions(-) create mode 100644 changes/int-range-patterns.feature.md create mode 100644 tests/build_tests/super_errors/expected/pattern_interval_float.res.expected delete mode 100644 tests/build_tests/super_errors/expected/pattern_interval_non_char.res.expected create mode 100644 tests/build_tests/super_errors/expected/warning_8_int_range_example.res.expected create mode 100644 tests/build_tests/super_errors/expected/warning_8_mixed_range_example.res.expected rename tests/build_tests/super_errors/fixtures/{pattern_interval_non_char.res => pattern_interval_float.res} (66%) create mode 100644 tests/build_tests/super_errors/fixtures/warning_8_int_range_example.res create mode 100644 tests/build_tests/super_errors/fixtures/warning_8_mixed_range_example.res create mode 100644 tests/tests/src/int_range_test.mjs create mode 100644 tests/tests/src/int_range_test.res diff --git a/changes/int-range-patterns.feature.md b/changes/int-range-patterns.feature.md new file mode 100644 index 00000000000..b667c235e2e --- /dev/null +++ b/changes/int-range-patterns.feature.md @@ -0,0 +1 @@ +- Int range patterns such as `200 .. 299` are supported in `switch`, and their bounds may mix ints and chars. diff --git a/compiler/ml/parmatch.ml b/compiler/ml/parmatch.ml index 39b8ccf23fd..b2eaf36a6ae 100644 --- a/compiler/ml/parmatch.ml +++ b/compiler/ml/parmatch.ml @@ -1140,18 +1140,29 @@ let build_other ext env : Typedtree.pattern = _ ) :: _ -> (* Int and char constants and intervals can share a column; the example is - the least value from 0 up that no range covers, of the kind of the - first pattern. Ranges sorted by lower bound are skipped in one pass. *) - let ranges = - List.filter_map (fun (q, _) -> int_range q) env - |> List.sort (fun (lo1, _) (lo2, _) -> compare lo1 lo2) + the least value from 0 up that no range covers, else the greatest one + below 0, of the kind of the first pattern. Ranges sorted by a bound are + skipped in one pass. *) + let ranges = List.filter_map (fun (q, _) -> int_range q) env in + let up = + List.sort (fun (lo1, _) (lo2, _) -> compare lo1 lo2) ranges + |> List.fold_left + (fun i (lo, hi) -> if lo <= i && i <= hi then hi + 1 else i) + 0 in - let other = - List.fold_left - (fun i (lo, hi) -> if lo <= i && i <= hi then hi + 1 else i) - 0 ranges + let down () = + List.sort (fun (_, hi1) (_, hi2) -> compare hi2 hi1) ranges + |> List.fold_left + (fun i (lo, hi) -> if lo <= i && i <= hi then lo - 1 else i) + (-1) in - make_int_range p other other + let int32_max = Int32.to_int Int32.max_int + and int32_min = Int32.to_int Int32.min_int in + if up <= int32_max then make_int_range p up up + else + let other = down () in + (* Every int is covered; ints are never matched exhaustively *) + if other < int32_min then omega else make_int_range p other other | (({pat_desc = Tpat_constant (Const_bigint _)} as p), _) :: _ -> build_other_constant (function diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml index cc3c0a4dfd3..a44a71133bc 100644 --- a/compiler/ml/typecore.ml +++ b/compiler/ml/typecore.ml @@ -1423,19 +1423,35 @@ and type_pat_aux ~constrs ~labels ~no_existentials ~mode ~explode ~env sp pat_attributes = sp.ppat_attributes; pat_env = !env; } - | Ppat_interval (Pconst_char {semantic = c1}, Pconst_char {semantic = c2}) -> - let lo = Const_char (min c1 c2) and hi = Const_char (max c1 c2) in - unify_pat_types loc !env (type_constant lo) expected_ty; - rp k - { - pat_desc = (if c1 = c2 then Tpat_constant lo else Tpat_interval (lo, hi)); - pat_loc = loc; - pat_extra = []; - pat_type = expected_ty; - pat_attributes = sp.ppat_attributes; - pat_env = !env; - } - | Ppat_interval _ -> raise (Error (loc, !env, Invalid_interval)) + | Ppat_interval (c1, c2) -> ( + (* [char] is an alias of [int], so bounds may mix both *) + let bound c = + match constant_or_raise !env loc c with + | (Const_int i | Const_char i) as c -> Some (i, c) + | Const_string _ | Const_float _ | Const_bigint _ -> None + in + match (bound c1, bound c2) with + | Some b1, Some b2 -> + let (i1, lo), (i2, hi) = + if fst b1 <= fst b2 then (b1, b2) else (b2, b1) + in + let ty = + match (lo, hi) with + | Const_char _, Const_char _ -> type_constant lo + | _ -> instance_def (Predef.type_int ()) + in + unify_pat_types loc !env ty expected_ty; + rp k + { + pat_desc = + (if i1 = i2 then Tpat_constant lo else Tpat_interval (lo, hi)); + pat_loc = loc; + pat_extra = []; + pat_type = expected_ty; + pat_attributes = sp.ppat_attributes; + pat_env = !env; + } + | _ -> raise (Error (loc, !env, Invalid_interval))) | Ppat_tuple spl -> assert (List.length spl >= 2); let spl_ann = List.map (fun p -> (p, newvar ())) spl in @@ -5369,7 +5385,7 @@ let report_error env loc ppf error = prefix in this pattern.@]" name Printtyp.path tpath | Invalid_interval -> - fprintf ppf "@[Only character intervals are supported in patterns.@]" + fprintf ppf "@[Only int and char intervals are supported in patterns.@]" | Invalid_for_loop_index -> fprintf ppf "@[Invalid for-loop index: only variables and _ are allowed.@]" | No_value_clauses -> diff --git a/tests/build_tests/super_errors/expected/pattern_interval_float.res.expected b/tests/build_tests/super_errors/expected/pattern_interval_float.res.expected new file mode 100644 index 00000000000..d277651dadb --- /dev/null +++ b/tests/build_tests/super_errors/expected/pattern_interval_float.res.expected @@ -0,0 +1,11 @@ + + We've found a bug for you! + /.../fixtures/pattern_interval_float.res:3:5-14 + + 1 │ let f = x => + 2 │ switch x { + 3 │ | 1.0 .. 5.0 => "low" + 4 │ | _ => "other" + 5 │ } + + Only int and char intervals are supported in patterns. \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/pattern_interval_non_char.res.expected b/tests/build_tests/super_errors/expected/pattern_interval_non_char.res.expected deleted file mode 100644 index 8048b4c37fa..00000000000 --- a/tests/build_tests/super_errors/expected/pattern_interval_non_char.res.expected +++ /dev/null @@ -1,11 +0,0 @@ - - We've found a bug for you! - /.../fixtures/pattern_interval_non_char.res:3:5-10 - - 1 │ let f = x => - 2 │ switch x { - 3 │ | 1 .. 5 => "low" - 4 │ | _ => "other" - 5 │ } - - Only character intervals are supported in patterns. \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/warning_8_int_range_example.res.expected b/tests/build_tests/super_errors/expected/warning_8_int_range_example.res.expected new file mode 100644 index 00000000000..a152a7094b7 --- /dev/null +++ b/tests/build_tests/super_errors/expected/warning_8_int_range_example.res.expected @@ -0,0 +1,14 @@ + + Warning number 8 + /.../fixtures/warning_8_int_range_example.res:3:3-6:3 + + 1 │ // All non-negative ints are covered, so the example is negative. + 2 │ let f = x => + 3 │ switch x { + 4 │  | 0 .. 0x7FFFFFFF => true + 5 │  | -10 .. -1 => false + 6 │  } + 7 │ + + You forgot to handle a possible case here, for example: + | -11 \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/warning_8_mixed_range_example.res.expected b/tests/build_tests/super_errors/expected/warning_8_mixed_range_example.res.expected new file mode 100644 index 00000000000..1a718d26f71 --- /dev/null +++ b/tests/build_tests/super_errors/expected/warning_8_mixed_range_example.res.expected @@ -0,0 +1,13 @@ + + Warning number 8 + /.../fixtures/warning_8_mixed_range_example.res:4:3-6:3 + + 2 │ // example is a negative int, not a char. + 3 │ let f = x => + 4 │ switch x { + 5 │  | '\x00' .. 0x7FFFFFFF => 1 + 6 │  } + 7 │ + + You forgot to handle a possible case here, for example: + | -1 \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/pattern_interval_non_char.res b/tests/build_tests/super_errors/fixtures/pattern_interval_float.res similarity index 66% rename from tests/build_tests/super_errors/fixtures/pattern_interval_non_char.res rename to tests/build_tests/super_errors/fixtures/pattern_interval_float.res index ebde9ef093d..45c6a0cceb9 100644 --- a/tests/build_tests/super_errors/fixtures/pattern_interval_non_char.res +++ b/tests/build_tests/super_errors/fixtures/pattern_interval_float.res @@ -1,5 +1,5 @@ let f = x => switch x { - | 1 .. 5 => "low" + | 1.0 .. 5.0 => "low" | _ => "other" } diff --git a/tests/build_tests/super_errors/fixtures/warning_8_int_range_example.res b/tests/build_tests/super_errors/fixtures/warning_8_int_range_example.res new file mode 100644 index 00000000000..5f055866d2d --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/warning_8_int_range_example.res @@ -0,0 +1,6 @@ +// All non-negative ints are covered, so the example is negative. +let f = x => + switch x { + | 0 .. 0x7FFFFFFF => true + | -10 .. -1 => false + } diff --git a/tests/build_tests/super_errors/fixtures/warning_8_mixed_range_example.res b/tests/build_tests/super_errors/fixtures/warning_8_mixed_range_example.res new file mode 100644 index 00000000000..07d566c13b0 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/warning_8_mixed_range_example.res @@ -0,0 +1,6 @@ +// A range from a char to an int covers all non-negative ints, so the +// example is a negative int, not a char. +let f = x => + switch x { + | '\x00' .. 0x7FFFFFFF => 1 + } diff --git a/tests/tests/src/int_range_test.mjs b/tests/tests/src/int_range_test.mjs new file mode 100644 index 00000000000..6e166d29915 --- /dev/null +++ b/tests/tests/src/int_range_test.mjs @@ -0,0 +1,155 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Mocha from "mocha"; +import * as Test_utils from "./test_utils.mjs"; + +function classify(x) { + if (x >= 10) { + if (x >= 32) { + if (x > 200 || x < 100) { + return "other"; + } else { + return "reversed"; + } + } else if (x >= 16) { + return "hex"; + } else { + return "other"; + } + } else if (x >= 0) { + if (x !== 0) { + return "digit"; + } else { + return "zero"; + } + } else if (x >= -5) { + return "negative"; + } else { + return "other"; + } +} + +function ascii(x) { + if (x >= 91) { + if (x > 122 || x < 97) { + return "other"; + } else { + return "lower"; + } + } else if (x >= 65) { + return "upper"; + } else { + return "other"; + } +} + +function large(x) { + if (x >= 0) { + if (x >= 1073741824) { + return "high"; + } else { + return "low"; + } + } else { + return "negative"; + } +} + +function status(code) { + if (code >= 309) { + if (code >= 500) { + if (code >= 600) { + return "unknown"; + } else { + return "server error"; + } + } else if (code >= 400) { + return "client error"; + } else { + return "unknown"; + } + } + if (code < 200) { + return "unknown"; + } + if (code < 300) { + return "success"; + } + switch (code) { + case 300 : + case 303 : + case 304 : + case 305 : + case 306 : + return "unknown"; + case 301 : + case 302 : + case 307 : + case 308 : + return "redirect"; + } +} + +function pair(a, b) { + if (a > 9 || a < 0) { + if (!(b > 9 || b < 0)) { + return "one"; + } + } else if (!(b > 9 || b < 0)) { + return "both"; + } + if (a > 9 || a < 0) { + return "none"; + } else { + return "one"; + } +} + +Mocha.describe("Int_range_test", () => { + Mocha.test("ranges", () => { + Test_utils.eq("File \"int_range_test.res\", line 47, characters 7-14", classify(-5), "negative"); + Test_utils.eq("File \"int_range_test.res\", line 48, characters 7-14", classify(-1), "negative"); + Test_utils.eq("File \"int_range_test.res\", line 49, characters 7-14", classify(-6), "other"); + Test_utils.eq("File \"int_range_test.res\", line 50, characters 7-14", classify(0), "zero"); + Test_utils.eq("File \"int_range_test.res\", line 51, characters 7-14", classify(9), "digit"); + Test_utils.eq("File \"int_range_test.res\", line 52, characters 7-14", classify(16), "hex"); + Test_utils.eq("File \"int_range_test.res\", line 53, characters 7-14", classify(31), "hex"); + Test_utils.eq("File \"int_range_test.res\", line 54, characters 7-14", classify(32), "other"); + Test_utils.eq("File \"int_range_test.res\", line 55, characters 7-14", classify(150), "reversed"); + }); + Mocha.test("mixed bounds", () => { + Test_utils.eq("File \"int_range_test.res\", line 59, characters 7-14", ascii(/* 'q' */113), "lower"); + Test_utils.eq("File \"int_range_test.res\", line 60, characters 7-14", ascii(81), "upper"); + Test_utils.eq("File \"int_range_test.res\", line 61, characters 7-14", ascii(/* '!' */33), "other"); + }); + Mocha.test("large ranges", () => { + Test_utils.eq("File \"int_range_test.res\", line 65, characters 7-14", large(-2147483648), "negative"); + Test_utils.eq("File \"int_range_test.res\", line 66, characters 7-14", large(0), "low"); + Test_utils.eq("File \"int_range_test.res\", line 67, characters 7-14", large(1073741823), "low"); + Test_utils.eq("File \"int_range_test.res\", line 68, characters 7-14", large(1073741824), "high"); + Test_utils.eq("File \"int_range_test.res\", line 69, characters 7-14", large(2147483647), "high"); + }); + Mocha.test("status codes", () => { + Test_utils.eq("File \"int_range_test.res\", line 73, characters 7-14", status(204), "success"); + Test_utils.eq("File \"int_range_test.res\", line 74, characters 7-14", status(302), "redirect"); + Test_utils.eq("File \"int_range_test.res\", line 75, characters 7-14", status(304), "unknown"); + Test_utils.eq("File \"int_range_test.res\", line 76, characters 7-14", status(404), "client error"); + Test_utils.eq("File \"int_range_test.res\", line 77, characters 7-14", status(503), "server error"); + Test_utils.eq("File \"int_range_test.res\", line 78, characters 7-14", status(600), "unknown"); + }); + Mocha.test("several columns", () => { + Test_utils.eq("File \"int_range_test.res\", line 82, characters 7-14", pair(1, 2), "both"); + Test_utils.eq("File \"int_range_test.res\", line 83, characters 7-14", pair(1, 20), "one"); + Test_utils.eq("File \"int_range_test.res\", line 84, characters 7-14", pair(20, 2), "one"); + Test_utils.eq("File \"int_range_test.res\", line 85, characters 7-14", pair(20, 20), "none"); + }); +}); + +export { + classify, + ascii, + large, + status, + pair, +} +/* Not a pure module */ diff --git a/tests/tests/src/int_range_test.res b/tests/tests/src/int_range_test.res new file mode 100644 index 00000000000..a79115d1aaf --- /dev/null +++ b/tests/tests/src/int_range_test.res @@ -0,0 +1,87 @@ +open Mocha +open Test_utils + +let classify = x => + switch x { + | -5 .. -1 => "negative" + | 0 => "zero" + | 1 .. 9 => "digit" + | 0x10 .. 0x1F => "hex" + | 200 .. 100 => "reversed" + | _ => "other" + } + +// Bounds may mix chars and ints, as `char` is an alias of `int`. +let ascii = x => + switch x { + | 'a' .. 122 => "lower" + | 65 .. 'Z' => "upper" + | _ => "other" + } + +let large = x => + switch x { + | -0x80000000 .. -1 => "negative" + | 0 .. 0x3FFFFFFF => "low" + | _ => "high" + } + +let status = code => + switch code { + | 200 .. 299 => "success" + | 301 | 302 | 307 | 308 => "redirect" + | 400 .. 499 => "client error" + | 500 .. 599 => "server error" + | _ => "unknown" + } + +let pair = (a, b) => + switch (a, b) { + | (0 .. 9, 0 .. 9) => "both" + | (0 .. 9, _) | (_, 0 .. 9) => "one" + | _ => "none" + } + +describe(__MODULE__, () => { + test("ranges", () => { + eq(__LOC__, classify(-5), "negative") + eq(__LOC__, classify(-1), "negative") + eq(__LOC__, classify(-6), "other") + eq(__LOC__, classify(0), "zero") + eq(__LOC__, classify(9), "digit") + eq(__LOC__, classify(16), "hex") + eq(__LOC__, classify(31), "hex") + eq(__LOC__, classify(32), "other") + eq(__LOC__, classify(150), "reversed") + }) + + test("mixed bounds", () => { + eq(__LOC__, ascii('q'), "lower") + eq(__LOC__, ascii(81), "upper") + eq(__LOC__, ascii('!'), "other") + }) + + test("large ranges", () => { + eq(__LOC__, large(-0x80000000), "negative") + eq(__LOC__, large(0), "low") + eq(__LOC__, large(0x3FFFFFFF), "low") + eq(__LOC__, large(0x40000000), "high") + eq(__LOC__, large(0x7FFFFFFF), "high") + }) + + test("status codes", () => { + eq(__LOC__, status(204), "success") + eq(__LOC__, status(302), "redirect") + eq(__LOC__, status(304), "unknown") + eq(__LOC__, status(404), "client error") + eq(__LOC__, status(503), "server error") + eq(__LOC__, status(600), "unknown") + }) + + test("several columns", () => { + eq(__LOC__, pair(1, 2), "both") + eq(__LOC__, pair(1, 20), "one") + eq(__LOC__, pair(20, 2), "one") + eq(__LOC__, pair(20, 20), "none") + }) +})