Skip to content

Commit c2005fd

Browse files
zthcristianoctsnobip
authored
Pattern matching for dicts (#7059)
* Draft dict pattern matching. Works for this: ``` type myDict = {name?:string, anyOtherField?: int} let tst = (d: myDict) => switch d { | {name:n, something:i} => String.length(n) + i | {name:n} => String.length(n) | {something:i} => i | _ => 0 } ``` * Avoid mis-firing when a field is legit missing. * Make lbl_all mutable. With lbl_all mutable, it can be extended when new fields are used in pattern matching. This handles examples with multiple fields: ``` type myDict = {name?:string, anyOtherField?: int} let tst = (d: myDict) => switch d { | {a:i, b:j} => i + j | _ => 0 } ``` * Add test for the various aspects of first class dicts. * update tests * make builtin dict type be a record with anyOtherField catch all * make typechecker account for res.dictPattern attribute to infer record pattern as dict pattern match when the type is not already known * format * add some tests, and disallow direct record field access on dicts * make code path handling the magic record field for dicts just work on the predefined dict * remove now irrelevant test since we reduced scope to just focus on dicts in the first iteration, not record-with-some-and-some-unknown-properties * remove lingering file * format * make sure coercion is disallowed for dicts * add internal test making sure dict labels dont stack * add more fields to test * comment + rename file * share a few definitions * no need to check tvar * remove comment * add more comments * syntax support * cleanup * add broken dict pattern parsing test * fix pattern matching of dict * comments and changelog * a few more comment tests * undo changelog formatting * fixes * simplify * add live attribute suppressing dead code analysis for dicts since they can't be statically analysed for unused fields --------- Co-authored-by: Cristiano Calcagno <[email protected]> Co-authored-by: Paul Tsnobiladzé <[email protected]>
1 parent 70700fe commit c2005fd

34 files changed

+619
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- Use FORCE_COLOR environmental variable to force colorized output https://github.com/rescript-lang/rescript-compiler/pull/7033
2222
- Allow spreads of variants in patterns (`| ...someVariant as v => `) when the variant spread is a subtype of the variant matched on. https://github.com/rescript-lang/rescript-compiler/pull/6721
2323
- Fix the issue where dynamic imports are not working for function-defined externals. https://github.com/rescript-lang/rescript-compiler/pull/7060
24+
- Allow pattern matching on dicts. `switch someDict { | dict{"one": 1} => Js.log("one is one") }` https://github.com/rescript-lang/rescript-compiler/pull/7059
2425

2526
#### :bug: Bug fix
2627

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
2+
We've found a bug for you!
3+
/.../fixtures/dict_coercion.res:7:10-30
4+
5+
5 │ type fakeDict<'t> = {dictValuesType?: 't}
6+
6 │
7+
7 │ let d = (dict :> fakeDict<int>)
8+
8 │
9+
10+
Type Js.Dict.t<int> = dict<int> is not a subtype of fakeDict<int>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
We've found a bug for you!
3+
/.../fixtures/dict_magic_field_on_non_dict.res:5:6-23
4+
5+
3 │ let foo = (fakeDict: fakeDict<'a>) => {
6+
4 │ switch fakeDict {
7+
5 │ | {someUndefinedField: 1} => Js.log("one")
8+
6 │ | _ => Js.log("not one")
9+
7 │ }
10+
11+
The field someUndefinedField does not belong to type fakeDict
12+
13+
This record pattern is expected to have type fakeDict<'a>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
We've found a bug for you!
3+
/.../fixtures/dict_pattern_inference.res:3:27-33
4+
5+
1 │ let foo = dict =>
6+
2 │ switch dict {
7+
3 │ | dict{"one": 1, "two": "hello"} => Js.log("one")
8+
4 │ | _ => Js.log("not one")
9+
5 │ }
10+
11+
This pattern matches values of type string
12+
but a pattern was expected which matches values of type int
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
2+
We've found a bug for you!
3+
/.../fixtures/dict_pattern_inference_constrained.res:4:27-30
4+
5+
2 ┆ switch dict {
6+
3 ┆ | dict{"one": 1} =>
7+
4 ┆ let _: dict<string> = dict
8+
5 ┆ Js.log("one")
9+
6 ┆ | _ => Js.log("not one")
10+
11+
This has type: dict<int>
12+
But it's expected to have type: dict<string>
13+
14+
The incompatible parts:
15+
int vs string
16+
17+
You can convert int to string with Belt.Int.toString.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
We've found a bug for you!
3+
/.../fixtures/dict_pattern_regular_record.res:5:5-22
4+
5+
3 │ let constrainedAsDict = (dict: x) =>
6+
4 │ switch dict {
7+
5 │ | dict{"one": "one"} => Js.log("one")
8+
6 │ | _ => Js.log("not one")
9+
7 │ }
10+
11+
This pattern matches values of type dict<string>
12+
but a pattern was expected which matches values of type x
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
2+
We've found a bug for you!
3+
/.../fixtures/dict_record_style_field_access.res:5:20-23
4+
5+
3 │ }
6+
4 │
7+
5 │ let x = stringDict.name
8+
9+
Direct field access on a dict is not supported. Use Dict.get instead.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
let dict = Js.Dict.empty()
2+
dict->Js.Dict.set("someKey1", 1)
3+
dict->Js.Dict.set("someKey2", 2)
4+
5+
type fakeDict<'t> = {dictValuesType?: 't}
6+
7+
let d = (dict :> fakeDict<int>)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
type fakeDict<'t> = {dictValuesType?: 't}
2+
3+
let foo = (fakeDict: fakeDict<'a>) => {
4+
switch fakeDict {
5+
| {someUndefinedField: 1} => Js.log("one")
6+
| _ => Js.log("not one")
7+
}
8+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
let foo = dict =>
2+
switch dict {
3+
| dict{"one": 1, "two": "hello"} => Js.log("one")
4+
| _ => Js.log("not one")
5+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
let foo = dict =>
2+
switch dict {
3+
| dict{"one": 1} =>
4+
let _: dict<string> = dict
5+
Js.log("one")
6+
| _ => Js.log("not one")
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
type x = {one: int}
2+
3+
let constrainedAsDict = (dict: x) =>
4+
switch dict {
5+
| dict{"one": "one"} => Js.log("one")
6+
| _ => Js.log("not one")
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
let stringDict = dict{
2+
"name": "hello",
3+
}
4+
5+
let x = stringDict.name

jscomp/ml/dict_type_helpers.ml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
(*
2+
An overview of the implementation of dicts in ReScript:
3+
### What is a dict?
4+
Dicts are effectively an object with unknown fields, but a single known type of the values it holds.
5+
6+
### How are they implemented?
7+
Dicts in ReScript are implemented as predefined record type, with a single (magic) field that holds
8+
the type of the dict's values. This field is called `dictValuesType`, and it represent every possible
9+
key in the dict. It's just an implementation detail - it's never actually exposed to the user, just
10+
used internally.
11+
12+
The compiler will route any label lookup on the dict record type to the magic field, which creates a
13+
record with unknown keys, but of a single type.
14+
15+
The reason for this seemingly convoluted implementation is that it allows us to piggyback on the
16+
existing record pattern matching mechanism, which means we get pattern matching on dicts for free.
17+
18+
### Modifications to the type checker
19+
We've made a few smaller modifications to the type checker to support this implementation:
20+
21+
- We've added a new predefined type `dict` that is a record with a single field called `dictValuesType`.
22+
This type is used to represent the type of the values in a dict.
23+
- We've modified the type checker to recognize `dict` patterns, and route them to the predefined `dict` type.
24+
This allows us to get full inference for dicts in patterns.
25+
26+
### Syntax
27+
There's first class syntax support for dicts, both as expressions and as patterns.
28+
A dict pattern is treated as a record pattern in the compiler and syntax, with an attriubute `@res.dictPattern`
29+
attached to it. This attribute is used to tell the compiler that the pattern is a dict pattern, and is what
30+
triggers the compiler to treat the dict record type differently to regular record types.
31+
*)
32+
let dict_magic_field_name = "dictValuesType"
33+
34+
let has_dict_pattern_attribute attrs =
35+
attrs
36+
|> List.find_opt (fun (({txt}, _) : Parsetree.attribute) ->
37+
txt = "res.dictPattern")
38+
|> Option.is_some
39+
40+
let has_dict_attribute attrs =
41+
attrs
42+
|> List.find_opt (fun (({txt}, _) : Parsetree.attribute) -> txt = "res.$dict")
43+
|> Option.is_some
44+
45+
let dict_attr : Parsetree.attribute =
46+
(Location.mknoloc "res.$dict", Parsetree.PStr [])
47+
48+
let dict_magic_field_attr : Parsetree.attribute =
49+
(Location.mknoloc "res.$dictMagicField", Parsetree.PStr [])

jscomp/ml/predef.ml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ and ident_failure = ident_create_predef_exn "Failure"
112112
and ident_ok = ident_create_predef_exn "Ok"
113113
and ident_error = ident_create_predef_exn "Error"
114114

115+
and ident_dict_magic_field_name = ident_create Dict_type_helpers.dict_magic_field_name
116+
115117
and ident_js_error = ident_create_predef_exn "JsError"
116118
and ident_not_found = ident_create_predef_exn "Not_found"
117119

@@ -218,10 +220,28 @@ let common_initial_env add_type add_extension empty_env =
218220
type_variance = [Variance.covariant; Variance.covariant]}
219221
and decl_dict =
220222
let tvar = newgenvar() in
223+
(* Dicts are implemented as a single "magic" field record. This magic field
224+
is the medium through which we can piggy back on the existing record pattern
225+
matching mechanism. We do this by letting the compiler route any label lookup
226+
for the dict record type to the magic field, which has the type of the values
227+
of the dict.
228+
229+
So, this definition is important for the dict pattern matching functionality,
230+
but not something intended to be exposed to the user. *)
221231
{decl_abstr with
232+
type_attributes = [Dict_type_helpers.dict_attr; (Location.mknoloc "live", Parsetree.PStr [])];
222233
type_params = [tvar];
223234
type_arity = 1;
224-
type_variance = [Variance.full]}
235+
type_variance = [Variance.full];
236+
type_kind = Type_record ([{
237+
ld_id = ident_dict_magic_field_name;
238+
ld_attributes = [(Location.mknoloc "res.optional", Parsetree.PStr []); Dict_type_helpers.dict_magic_field_attr];
239+
ld_loc = Location.none;
240+
ld_mutable = Immutable;
241+
ld_type = newgenty (Tconstr (path_option, [tvar], ref Mnil));
242+
}],
243+
Record_optional_labels [Ident.name ident_dict_magic_field_name]);
244+
}
225245
and decl_uncurried =
226246
let tvar1, tvar2 = newgenvar(), newgenvar() in
227247
{decl_abstr with

jscomp/ml/typecore.ml

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ type error =
7575
| Uncurried_arity_mismatch of type_expr * int * int
7676
| Field_not_optional of string * type_expr
7777
| Type_params_not_supported of Longident.t
78+
| Field_access_on_dict_type
7879
exception Error of Location.t * Env.t * error
7980
exception Error_forward of Location.error
8081

@@ -788,6 +789,8 @@ module NameChoice(Name : sig
788789
val get_name: t -> string
789790
val get_type: t -> type_expr
790791
val get_descrs: Env.type_descriptions -> t list
792+
793+
val unsafe_do_not_use__add_with_name: t -> string -> t
791794
val unbound_name_error: Env.t -> Longident.t loc -> 'a
792795

793796
end) = struct
@@ -798,10 +801,18 @@ end) = struct
798801
| Tconstr(p, _, _) -> p
799802
| _ -> assert false
800803

801-
let lookup_from_type env tpath lid =
804+
let lookup_from_type env tpath (lid : Longident.t loc) : Name.t =
802805
let descrs = get_descrs (Env.find_type_descrs tpath env) in
803806
Env.mark_type_used env (Path.last tpath) (Env.find_type tpath env);
804-
match lid.txt with
807+
if Path.same tpath Predef.path_dict then (
808+
(* [dict] Handle directing any label lookup to the magic dict field. *)
809+
match lid.txt with
810+
Longident.Lident s -> begin
811+
let x = List.find (fun nd -> get_name nd = Dict_type_helpers.dict_magic_field_name) descrs in
812+
unsafe_do_not_use__add_with_name x s
813+
end
814+
| _ -> raise Not_found)
815+
else match lid.txt with
805816
Longident.Lident s -> begin
806817
try
807818
List.find (fun nd -> get_name nd = s) descrs
@@ -884,6 +895,20 @@ module Label = NameChoice (struct
884895
type t = label_description
885896
let type_kind = "record"
886897
let get_name lbl = lbl.lbl_name
898+
899+
let unsafe_do_not_use__add_with_name lbl name =
900+
(* [dict] This is used in dicts and shouldn't be used anywhere else.
901+
It adds a new field to an existing record type, to "fool" the pattern
902+
matching into thinking the label exists. *)
903+
let l =
904+
{lbl with
905+
lbl_name = name;
906+
lbl_pos = Array.length lbl.lbl_all;
907+
lbl_repres = Record_optional_labels [name]} in
908+
let lbl_all_list = Array.to_list lbl.lbl_all @ [l] in
909+
let lbl_all = Array.of_list lbl_all_list in
910+
Ext_array.iter lbl_all (fun lbl -> lbl.lbl_all <- lbl_all);
911+
l
887912
let get_type lbl = lbl.lbl_res
888913
let get_descrs = snd
889914
let unbound_name_error = Typetexp.unbound_label_error
@@ -1040,6 +1065,8 @@ module Constructor = NameChoice (struct
10401065
let type_kind = "variant"
10411066
let get_name cstr = cstr.cstr_name
10421067
let get_type cstr = cstr.cstr_res
1068+
1069+
let unsafe_do_not_use__add_with_name _cstr _name = assert false
10431070
let get_descrs = fst
10441071
let unbound_name_error = Typetexp.unbound_constructor_error
10451072
end)
@@ -1348,12 +1375,17 @@ and type_pat_aux ~constrs ~labels ~no_existentials ~mode ~explode ~env
13481375
| _ -> k None
13491376
end
13501377
| Ppat_record(lid_sp_list, closed) ->
1351-
let opath, record_ty =
1378+
let has_dict_pattern_attr = Dict_type_helpers.has_dict_pattern_attribute sp.ppat_attributes in
1379+
let opath, record_ty = (
1380+
if has_dict_pattern_attr then (
1381+
(* [dict] Make sure dict patterns are inferred as actual dicts *)
1382+
(Some (Predef.path_dict, Predef.path_dict), newgenty (Tconstr (Predef.path_dict, [newvar ()], ref Mnil)))
1383+
) else
13521384
try
13531385
let (p0, p, _, _) = extract_concrete_record !env expected_ty in
13541386
Some (p0, p), expected_ty
13551387
with Not_found -> None, newvar ()
1356-
in
1388+
) in
13571389
let get_jsx_component_error_info = get_jsx_component_error_info ~extract_concrete_typedecl opath !env record_ty in
13581390
let process_optional_label (ld, pat) =
13591391
let exp_optional_attr = check_optional_attr !env ld pat.ppat_attributes pat.ppat_loc in
@@ -2983,8 +3015,16 @@ and type_label_access env srecord lid =
29833015
let ty_exp = record.exp_type in
29843016
let opath =
29853017
try
2986-
let (p0, p, _, _) = extract_concrete_record env ty_exp in
2987-
Some(p0, p)
3018+
match extract_concrete_typedecl env ty_exp with
3019+
| (p0, _, {type_attributes})
3020+
when Path.same p0 Predef.path_dict && Dict_type_helpers.has_dict_attribute type_attributes ->
3021+
(* [dict] Cover the case when trying to direct field access on a dict, e.g. `someDict.name`.
3022+
We need to disallow this because the fact that a dict is represented as a single magic
3023+
field record internally is just an implementation detail, and not intended to be exposed
3024+
to the user. *)
3025+
raise(Error(lid.loc, env, Field_access_on_dict_type))
3026+
| (p0, p, {type_kind=Type_record _}) -> Some(p0, p)
3027+
| _ -> None
29883028
with Not_found -> None
29893029
in
29903030
let labels = Typetexp.find_all_labels env lid.loc lid.txt in
@@ -4101,6 +4141,8 @@ let report_error env ppf = function
41014141
type_expr typ
41024142
| Type_params_not_supported lid ->
41034143
fprintf ppf "The type %a@ has type parameters, but type parameters is not supported here." longident lid
4144+
| Field_access_on_dict_type ->
4145+
fprintf ppf "Direct field access on a dict is not supported. Use Dict.get instead."
41044146
41054147
41064148
let super_report_error_no_wrap_printing_env = report_error

jscomp/ml/typecore.mli

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ type error =
108108
| Uncurried_arity_mismatch of type_expr * int * int
109109
| Field_not_optional of string * type_expr
110110
| Type_params_not_supported of Longident.t
111+
| Field_access_on_dict_type
111112
exception Error of Location.t * Env.t * error
112113
exception Error_forward of Location.error
113114

jscomp/ml/types.ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ type label_description =
303303
lbl_arg: type_expr; (* Type of the argument *)
304304
lbl_mut: mutable_flag; (* Is this a mutable field? *)
305305
lbl_pos: int; (* Position in block *)
306-
lbl_all: label_description array; (* All the labels in this type *)
306+
mutable lbl_all: label_description array; (* All the labels in this type. This is mutable only because of a specific feature related to dicts, and should not be mutated elsewhere. *)
307307
lbl_repres: record_representation; (* Representation for this record *)
308308
lbl_private: private_flag; (* Read-only field? *)
309309
lbl_loc: Location.t;

jscomp/ml/types.mli

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,7 @@ type label_description =
449449
lbl_arg: type_expr; (* Type of the argument *)
450450
lbl_mut: mutable_flag; (* Is this a mutable field? *)
451451
lbl_pos: int; (* Position in block *)
452-
lbl_all: label_description array; (* All the labels in this type *)
453-
lbl_repres: record_representation; (* Representation for this record *)
452+
mutable lbl_all: label_description array; (* All the labels in this type. This is mutable only because of a specific feature related to dicts, and should not be mutated elsewhere. *) lbl_repres: record_representation; (* Representation for this record *)
454453
lbl_private: private_flag; (* Read-only field? *)
455454
lbl_loc: Location.t;
456455
lbl_attributes: Parsetree.attributes;

0 commit comments

Comments
 (0)