From 2a356c4c71723a91b404525edee4bbc786f5c2bb Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 14:12:32 +0200 Subject: [PATCH 1/5] dict spreads --- compiler/syntax/src/res_comments_table.ml | 16 +++ compiler/syntax/src/res_core.ml | 118 ++++++++++++++---- compiler/syntax/src/res_grammar.ml | 2 +- compiler/syntax/src/res_parsetree_viewer.ml | 76 ++++++++++- compiler/syntax/src/res_parsetree_viewer.mli | 8 ++ compiler/syntax/src/res_printer.ml | 106 +++++++++++++--- packages/@rescript/runtime/Primitive_dict.res | 3 + .../data/parsing/errors/other/dict_spread.res | 3 +- .../errors/other/expected/dict_spread.res.txt | 11 +- .../data/parsing/grammar/expressions/dict.res | 6 + .../grammar/expressions/expected/dict.res.txt | 14 ++- tests/syntax_tests/data/printer/expr/dict.res | 23 ++++ .../data/printer/expr/expected/dict.res.txt | 23 ++++ tests/tests/src/stdlib/Stdlib_DictTests.mjs | 109 ++++++++++++++-- tests/tests/src/stdlib/Stdlib_DictTests.res | 61 +++++++++ 15 files changed, 520 insertions(+), 59 deletions(-) diff --git a/compiler/syntax/src/res_comments_table.ml b/compiler/syntax/src/res_comments_table.ml index 2130cb19fae..e78dc7cb46c 100644 --- a/compiler/syntax/src/res_comments_table.ml +++ b/compiler/syntax/src/res_comments_table.ml @@ -1488,6 +1488,22 @@ and walk_expression expr t comments = attach t.trailing expr.pexp_loc after_expr; walk_list (cases |> List.map (fun case -> Case case)) t rest (* unary expression: todo use parsetreeviewer *) + | Pexp_apply _ + when Option.is_some + (Res_parsetree_viewer.collect_spread_dict_expr_parts expr) -> ( + match Res_parsetree_viewer.collect_spread_dict_expr_parts expr with + | Some parts -> + let part_exprs = + List.map + (function + | Res_parsetree_viewer.DictExprRows rows_expr -> + Expression rows_expr + | Res_parsetree_viewer.DictExprSpread spread_expr -> + Expression spread_expr) + parts + in + walk_list part_exprs t comments + | None -> assert false) | Pexp_apply { funct = diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index 2b6d7fe25ae..4b849df8ae9 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -150,8 +150,6 @@ module ErrorMessages = struct ...b}` wouldn't make sense, as `b` would override every field of `a` \ anyway." - let dict_expr_spread = "Dict literals do not support spread (`...`) yet." - let record_field_missing_colon = "Records use `:` when assigning fields. Example: `{field: value}`" @@ -285,6 +283,7 @@ let tagged_template_literal_attr = (Location.mknoloc "res.taggedTemplate", Parsetree.PStr []) let spread_attr = (Location.mknoloc "res.spread", Parsetree.PStr []) +let dict_spread_attr = (Location.mknoloc "res.dictSpread", Parsetree.PStr []) type argument = {label: Asttypes.arg_label; expr: Parsetree.expression} @@ -3505,14 +3504,12 @@ and parse_record_expr_row p : None) else None -and parse_dict_expr_row p = +and parse_dict_expr_part p = match p.Parser.token with | DotDotDot -> - Parser.err p (Diagnostics.message ErrorMessages.dict_expr_spread); Parser.next p; - (* Parse the expr so it's consumed *) - let _spread_expr = parse_constrained_or_coerced_expr p in - None + let spread_expr = parse_constrained_or_coerced_expr p in + Some (`Spread spread_expr) | String s -> ( let loc = mk_loc p.start_pos p.end_pos in Parser.next p; @@ -3520,15 +3517,15 @@ and parse_dict_expr_row p = match p.Parser.token with | Colon -> Parser.next p; - let fieldExpr = parse_expr p in - Some (field, fieldExpr) + let field_expr = parse_expr p in + Some (`Row (field, field_expr)) | Equal -> Parser.err ~start_pos:p.start_pos ~end_pos:p.end_pos p (Diagnostics.message ErrorMessages.dict_field_missing_colon); Parser.next p; - let fieldExpr = parse_expr p in - Some (field, fieldExpr) - | _ -> Some (field, Ast_helper.Exp.ident ~loc:field.loc field)) + let field_expr = parse_expr p in + Some (`Row (field, field_expr)) + | _ -> Some (`Row (field, Ast_helper.Exp.ident ~loc:field.loc field))) | _ -> None and parse_record_expr_with_string_keys ~start_pos first_row p = @@ -4374,9 +4371,9 @@ and parse_list_expr ~start_pos p = [(Asttypes.Nolabel, Ast_helper.Exp.array ~loc list_exprs)] and parse_dict_expr ~start_pos p = - let rows = + let parts = parse_comma_delimited_region ~grammar:Grammar.DictRows ~closing:Rbrace - ~f:parse_dict_expr_row p + ~f:parse_dict_expr_part p in let loc = mk_loc start_pos p.end_pos in let to_key_value_pair @@ -4393,14 +4390,93 @@ and parse_dict_expr ~start_pos p = ]) | _ -> None in - let key_value_pairs = List.filter_map to_key_value_pair rows in + let dict_rows_loc + (rows : (Longident.t Location.loc * Parsetree.expression) list) = + match (rows, List.rev rows) with + | (first_key, _) :: _, (_, last_expr) :: _ -> + mk_loc first_key.loc.loc_start last_expr.pexp_loc.loc_end + | _ -> loc + in + let make_dict_chunk ?loc_override rows = + let chunk_loc = + match loc_override with + | Some loc -> loc + | None -> dict_rows_loc rows + in + let key_value_pairs = List.filter_map to_key_value_pair rows in + Ast_helper.Exp.apply ~loc:chunk_loc + (Ast_helper.Exp.ident ~loc:chunk_loc + (Location.mkloc + (Longident.Ldot (Longident.Lident Primitive_modules.dict, "make")) + chunk_loc)) + [(Asttypes.Nolabel, Ast_helper.Exp.array ~loc:chunk_loc key_value_pairs)] + in + let grouped_parts = + let rec loop current_rows acc = function + | [] -> + let acc = + match current_rows with + | [] -> acc + | rows -> `Rows (List.rev rows) :: acc + in + List.rev acc + | `Row row :: rest -> loop (row :: current_rows) acc rest + | `Spread spread_expr :: rest -> + let acc = + match current_rows with + | [] -> `Spread spread_expr :: acc + | rows -> `Spread spread_expr :: `Rows (List.rev rows) :: acc + in + loop [] acc rest + in + loop [] [] parts + in Parser.expect Rbrace p; - Ast_helper.Exp.apply ~loc - (Ast_helper.Exp.ident ~loc - (Location.mkloc - (Longident.Ldot (Longident.Lident Primitive_modules.dict, "make")) - loc)) - [(Asttypes.Nolabel, Ast_helper.Exp.array ~loc key_value_pairs)] + match grouped_parts with + | [] -> make_dict_chunk ~loc_override:loc [] + | [`Rows rows] -> make_dict_chunk ~loc_override:loc rows + | `Rows target_rows :: source_parts -> + let spread_ident = + Ast_helper.Exp.ident ~loc ~attrs:[dict_spread_attr] + (Location.mkloc + (Longident.Ldot (Longident.Lident Primitive_modules.dict, "spread")) + loc) + in + let spread = + Ast_helper.Exp.apply ~loc spread_ident + [ + (Asttypes.Nolabel, make_dict_chunk target_rows); + ( Asttypes.Nolabel, + Ast_helper.Exp.array ~loc + (List.map + (function + | `Rows rows -> make_dict_chunk rows + | `Spread spread_expr -> spread_expr) + source_parts) ); + ] + in + spread + | source_parts -> + let spread_ident = + Ast_helper.Exp.ident ~loc ~attrs:[dict_spread_attr] + (Location.mkloc + (Longident.Ldot (Longident.Lident Primitive_modules.dict, "spread")) + loc) + in + let spread = + Ast_helper.Exp.apply ~loc spread_ident + [ + (Asttypes.Nolabel, make_dict_chunk []); + ( Asttypes.Nolabel, + Ast_helper.Exp.array ~loc + (List.map + (function + | `Rows rows -> make_dict_chunk rows + | `Spread spread_expr -> spread_expr) + source_parts) ); + ] + in + spread and parse_array_exp p = let start_pos = p.Parser.start_pos in diff --git a/compiler/syntax/src/res_grammar.ml b/compiler/syntax/src/res_grammar.ml index c33e6dcebdb..9b9a9514ab5 100644 --- a/compiler/syntax/src/res_grammar.ml +++ b/compiler/syntax/src/res_grammar.ml @@ -224,7 +224,7 @@ let is_mod_expr_start = function | _ -> false let is_dict_row_start = function - | Token.String _ -> true + | Token.DotDotDot | Token.String _ -> true | _ -> false let is_record_row_start = function diff --git a/compiler/syntax/src/res_parsetree_viewer.ml b/compiler/syntax/src/res_parsetree_viewer.ml index 7e8652b1cfa..6c541ca15b8 100644 --- a/compiler/syntax/src/res_parsetree_viewer.ml +++ b/compiler/syntax/src/res_parsetree_viewer.ml @@ -86,6 +86,16 @@ let has_dict_pattern_attribute attrs = txt = "res.dictPattern") |> Option.is_some +let has_dict_spread_attribute attrs = + attrs + |> List.find_opt (fun (({txt}, _) : Parsetree.attribute) -> + txt = "res.dictSpread") + |> Option.is_some + +type dict_expr_part = + | DictExprRows of Parsetree.expression + | DictExprSpread of Parsetree.expression + let collect_array_expressions expr = match expr.pexp_desc with | Pexp_array exprs -> (exprs, None) @@ -250,7 +260,7 @@ let filter_parsing_attrs attrs = Location.txt = ( "res.braces" | "ns.braces" | "res.iflet" | "res.ternary" | "res.await" | "res.template" | "res.taggedTemplate" - | "res.patVariantSpread" | "res.dictPattern" + | "res.patVariantSpread" | "res.dictPattern" | "res.dictSpread" | "res.inlineRecordDefinition" ); }, _ ) -> @@ -586,7 +596,7 @@ let is_printable_attribute attr = Location.txt = ( "res.iflet" | "res.braces" | "ns.braces" | "JSX" | "res.await" | "res.template" | "res.taggedTemplate" | "res.ternary" - | "res.inlineRecordDefinition" ); + | "res.inlineRecordDefinition" | "res.dictSpread" ); }, _ ) -> false @@ -738,6 +748,68 @@ let is_spread_belt_array_concat expr = has_spread_attr expr.pexp_attributes | _ -> false +let collect_spread_dict_expr_parts expr = + let is_tuple_array_expr (expr : Parsetree.expression) = + let is_plain_tuple (expr : Parsetree.expression) = + match expr with + | {pexp_desc = Pexp_tuple _} -> true + | _ -> false + in + match expr with + | {pexp_desc = Pexp_array items} -> List.for_all is_plain_tuple items + | _ -> false + in + let extract_literal_dict_rows (expr : Parsetree.expression) = + match expr with + | { + pexp_desc = + Pexp_apply + { + funct = + { + pexp_desc = + Pexp_ident + {txt = Longident.Ldot (Lident "Primitive_dict", "make")}; + }; + args = [(Nolabel, key_values)]; + }; + } + when is_tuple_array_expr key_values -> + Some key_values + | _ -> None + in + let is_empty_tuple_array (expr : Parsetree.expression) = + match expr.pexp_desc with + | Pexp_array [] -> true + | _ -> false + in + match expr with + | { + pexp_desc = + Pexp_apply + { + funct = + { + pexp_desc = + Pexp_ident + {txt = Longident.Ldot (Lident "Primitive_dict", "spread")}; + pexp_attributes; + }; + args = + [(Nolabel, target_expr); (Nolabel, {pexp_desc = Pexp_array sources})]; + }; + } + when has_dict_spread_attribute pexp_attributes -> + let to_part expr = + match extract_literal_dict_rows expr with + | Some rows_expr -> + if is_empty_tuple_array rows_expr then None + else Some (DictExprRows rows_expr) + | None -> Some (DictExprSpread expr) + in + Some (List.filter_map to_part (target_expr :: sources)) + | _ -> None + (* Blue | Red | Green -> [Blue; Red; Green] *) let collect_or_pattern_chain pat = let rec loop pattern chain = diff --git a/compiler/syntax/src/res_parsetree_viewer.mli b/compiler/syntax/src/res_parsetree_viewer.mli index 5fcfb6883c0..8d4536717fe 100644 --- a/compiler/syntax/src/res_parsetree_viewer.mli +++ b/compiler/syntax/src/res_parsetree_viewer.mli @@ -17,6 +17,11 @@ val has_await_attribute : Parsetree.attributes -> bool val has_inline_record_definition_attribute : Parsetree.attributes -> bool val has_res_pat_variant_spread_attribute : Parsetree.attributes -> bool val has_dict_pattern_attribute : Parsetree.attributes -> bool +val has_dict_spread_attribute : Parsetree.attributes -> bool + +type dict_expr_part = + | DictExprRows of Parsetree.expression + | DictExprSpread of Parsetree.expression type if_condition_kind = | If of Parsetree.expression @@ -132,6 +137,9 @@ val is_spread_belt_list_concat : Parsetree.expression -> bool val is_spread_belt_array_concat : Parsetree.expression -> bool +val collect_spread_dict_expr_parts : + Parsetree.expression -> dict_expr_part list option + val collect_or_pattern_chain : Parsetree.pattern -> Parsetree.pattern list val process_braces_attr : diff --git a/compiler/syntax/src/res_printer.ml b/compiler/syntax/src/res_printer.ml index 3ec990fcfde..063ccd50051 100644 --- a/compiler/syntax/src/res_printer.ml +++ b/compiler/syntax/src/res_printer.ml @@ -1629,10 +1629,7 @@ and print_record_declaration ?check_break_from_loc ?inline_record_definitions Doc.rbrace; ]) -and print_literal_dict_expr ~state (e : Parsetree.expression) cmt_tbl = - let force_break = - e.pexp_loc.loc_start.pos_lnum < e.pexp_loc.loc_end.pos_lnum - in +and collect_literal_dict_rows (e : Parsetree.expression) = let tuple_to_row (e : Parsetree.expression) = match e with | { @@ -1645,11 +1642,28 @@ and print_literal_dict_expr ~state (e : Parsetree.expression) cmt_tbl = Some ((Location.mkloc (Longident.Lident name) pexp_loc, value), e) | _ -> None in - let rows = - match e with - | {pexp_desc = Pexp_array expressions} -> - List.filter_map tuple_to_row expressions - | _ -> [] + match e with + | {pexp_desc = Pexp_array expressions} -> + List.filter_map tuple_to_row expressions + | _ -> [] + +and print_literal_dict_rows ~state ?(leading_line = true) + ?(trailing_comma = true) (e : Parsetree.expression) cmt_tbl = + let rows = collect_literal_dict_rows e in + let rows_doc = + Doc.join + ~sep:(Doc.concat [Doc.text ","; Doc.line]) + (List.map + (fun ((row, e) : + (Longident.t Location.loc * Parsetree.expression) + * Parsetree.expression) -> + let doc = print_bs_object_row ~state row cmt_tbl in + print_comments doc cmt_tbl e.pexp_loc) + rows) + in + let force_break = + e.pexp_loc.loc_start.pos_lnum < e.pexp_loc.loc_end.pos_lnum + || Doc.will_break rows_doc in Doc.breakable_group ~force_break (Doc.concat @@ -1657,21 +1671,67 @@ and print_literal_dict_expr ~state (e : Parsetree.expression) cmt_tbl = Doc.indent (Doc.concat [ - (if rows = [] then Doc.nil else Doc.soft_line); - Doc.join - ~sep:(Doc.concat [Doc.text ","; Doc.line]) - (List.map - (fun ((row, e) : - (Longident.t Location.loc * Parsetree.expression) - * Parsetree.expression) -> - let doc = print_bs_object_row ~state row cmt_tbl in - print_comments doc cmt_tbl e.pexp_loc) - rows); + (if rows = [] || not leading_line then Doc.nil else Doc.soft_line); + rows_doc; ]); - (if rows = [] then Doc.nil + (if rows = [] || not trailing_comma then Doc.nil else Doc.concat [Doc.trailing_comma; Doc.soft_line]); ]) +and print_literal_dict_expr ~state (e : Parsetree.expression) cmt_tbl = + print_literal_dict_rows ~state e cmt_tbl + +and print_spread_dict_expr ~state parts (expr : Parsetree.expression) cmt_tbl = + let print_spread_part spread_expr = + let leading_comments_doc = + print_leading_comments Doc.nil cmt_tbl.CommentTable.leading + spread_expr.Parsetree.pexp_loc + in + let spread_doc = + let doc = print_expression ~state spread_expr cmt_tbl in + match Parens.expr spread_expr with + | Parens.Parenthesized -> add_parens doc + | Braced braces -> print_braces doc spread_expr braces + | Nothing -> doc + in + let spread_with_trailing_comments = + print_trailing_comments spread_doc cmt_tbl.CommentTable.trailing + spread_expr.Parsetree.pexp_loc + in + Doc.concat + [leading_comments_doc; Doc.dotdotdot; spread_with_trailing_comments] + in + let parts_doc = + Doc.join + ~sep:(Doc.concat [Doc.text ","; Doc.line]) + (List.map + (function + | ParsetreeViewer.DictExprRows rows_expr -> + print_literal_dict_rows ~state ~leading_line:false + ~trailing_comma:false rows_expr cmt_tbl + | ParsetreeViewer.DictExprSpread spread_expr -> + print_spread_part spread_expr) + parts) + in + let inside_comments_doc = print_comments_inside cmt_tbl expr.pexp_loc in + let force_break = + expr.pexp_loc.loc_start.pos_lnum < expr.pexp_loc.loc_end.pos_lnum + || Doc.will_break inside_comments_doc + || Doc.will_break parts_doc + in + Doc.breakable_group ~force_break + (Doc.concat + [ + Doc.text "dict{"; + inside_comments_doc; + Doc.indent + (Doc.concat + [(if parts = [] then Doc.nil else Doc.soft_line); parts_doc]); + (if parts = [] then Doc.nil + else Doc.concat [Doc.trailing_comma; Doc.soft_line]); + Doc.rbrace; + ]) + and print_constructor_declarations ~state ~private_flag (cds : Parsetree.constructor_declaration list) cmt_tbl = let force_break = @@ -4486,6 +4546,12 @@ and print_pexp_apply ~state expr cmt_tbl = | [] -> doc | attrs -> Doc.group (Doc.concat [print_attributes ~state attrs cmt_tbl; doc])) + | Pexp_apply _ + when Option.is_some + (Res_parsetree_viewer.collect_spread_dict_expr_parts expr) -> ( + match Res_parsetree_viewer.collect_spread_dict_expr_parts expr with + | Some parts -> print_spread_dict_expr ~state parts expr cmt_tbl + | None -> assert false) | Pexp_apply { funct = diff --git a/packages/@rescript/runtime/Primitive_dict.res b/packages/@rescript/runtime/Primitive_dict.res index 6b9ee3f66a9..ab570b2b383 100644 --- a/packages/@rescript/runtime/Primitive_dict.res +++ b/packages/@rescript/runtime/Primitive_dict.res @@ -1,2 +1,5 @@ // Note: this is exposed to support syntax external make: array<(string, 'a)> => dict<'a> = "%makedict" + +// Note: this is exposed to support syntax +@variadic @val external spread: (dict<'a>, array>) => dict<'a> = "Object.assign" diff --git a/tests/syntax_tests/data/parsing/errors/other/dict_spread.res b/tests/syntax_tests/data/parsing/errors/other/dict_spread.res index 2bdc3945574..a8c1e321c83 100644 --- a/tests/syntax_tests/data/parsing/errors/other/dict_spread.res +++ b/tests/syntax_tests/data/parsing/errors/other/dict_spread.res @@ -1,2 +1 @@ -let x = dict{...foo, "bar": 3} - +let x = dict{...} diff --git a/tests/syntax_tests/data/parsing/errors/other/expected/dict_spread.res.txt b/tests/syntax_tests/data/parsing/errors/other/expected/dict_spread.res.txt index 5c5856aeb3c..bade296433f 100644 --- a/tests/syntax_tests/data/parsing/errors/other/expected/dict_spread.res.txt +++ b/tests/syntax_tests/data/parsing/errors/other/expected/dict_spread.res.txt @@ -1,11 +1,12 @@ Syntax error! - syntax_tests/data/parsing/errors/other/dict_spread.res:1:14-16 + syntax_tests/data/parsing/errors/other/dict_spread.res:1:17 - 1 │ let x = dict{...foo, "bar": 3} + 1 │ let x = dict{...} 2 │ - 3 │ - Dict literals do not support spread (`...`) yet. + Missing expression -let x = Primitive_dict.make [|("bar", 3)|] \ No newline at end of file +let x = + ((Primitive_dict.spread)[@res.dictSpread ]) (Primitive_dict.make [||]) + [|([%rescript.exprhole ])|] \ No newline at end of file diff --git a/tests/syntax_tests/data/parsing/grammar/expressions/dict.res b/tests/syntax_tests/data/parsing/grammar/expressions/dict.res index 20aacf134b2..9e7e37b2319 100644 --- a/tests/syntax_tests/data/parsing/grammar/expressions/dict.res +++ b/tests/syntax_tests/data/parsing/grammar/expressions/dict.res @@ -9,3 +9,9 @@ let x = dict{"foo": "bar", "bar": "baz"} let baz = "foo" let x = dict{"foo": "bar", "bar": "baz", "baz": baz} + +let foo = dict{"a": 1} +let qux = dict{"c": 3} +let x = dict{...foo, "bar": 2, ...qux} +let x = dict{"before": 1, ...foo, "after": 2} +let x = dict{...foo} diff --git a/tests/syntax_tests/data/parsing/grammar/expressions/expected/dict.res.txt b/tests/syntax_tests/data/parsing/grammar/expressions/expected/dict.res.txt index f0e8bcb6ee1..59bf2a29da8 100644 --- a/tests/syntax_tests/data/parsing/grammar/expressions/expected/dict.res.txt +++ b/tests/syntax_tests/data/parsing/grammar/expressions/expected/dict.res.txt @@ -4,4 +4,16 @@ let x = Primitive_dict.make [|("foo", {js|bar|js});("bar", {js|baz|js})|] let baz = {js|foo|js} let x = Primitive_dict.make - [|("foo", {js|bar|js});("bar", {js|baz|js});("baz", baz)|] \ No newline at end of file + [|("foo", {js|bar|js});("bar", {js|baz|js});("baz", baz)|] +let foo = Primitive_dict.make [|("a", 1)|] +let qux = Primitive_dict.make [|("c", 3)|] +let x = + ((Primitive_dict.spread)[@res.dictSpread ]) (Primitive_dict.make [||]) + [|foo;(Primitive_dict.make [|("bar", 2)|]);qux|] +let x = + ((Primitive_dict.spread)[@res.dictSpread ]) + (Primitive_dict.make [|("before", 1)|]) + [|foo;(Primitive_dict.make [|("after", 2)|])|] +let x = + ((Primitive_dict.spread)[@res.dictSpread ]) (Primitive_dict.make [||]) + [|foo|] \ No newline at end of file diff --git a/tests/syntax_tests/data/printer/expr/dict.res b/tests/syntax_tests/data/printer/expr/dict.res index 0a05246e983..3c562a41f02 100644 --- a/tests/syntax_tests/data/printer/expr/dict.res +++ b/tests/syntax_tests/data/printer/expr/dict.res @@ -60,3 +60,26 @@ let x = dict{ "bar": "baz" /* bar */, "baz": baz /* bar */ } + +let foo = dict{"a": 1} +let qux = dict{"c": 3} + +let x = dict{...foo, "bar": 2, ...qux} + +let x = dict{ + "before": 1, + ...foo, + "after": 2, +} + +let x = dict{ + /* before foo */ ...foo, + /* before bar */ "bar": 2, + /* before baz */ ...qux, +} + +let x = dict{ + ...foo /* after foo */, + "bar": 2, + ...qux /* after qux */, +} diff --git a/tests/syntax_tests/data/printer/expr/expected/dict.res.txt b/tests/syntax_tests/data/printer/expr/expected/dict.res.txt index c93760b4d21..e30515371f6 100644 --- a/tests/syntax_tests/data/printer/expr/expected/dict.res.txt +++ b/tests/syntax_tests/data/printer/expr/expected/dict.res.txt @@ -59,3 +59,26 @@ let x = dict{ "bar": "baz" /* bar */, "baz": baz /* bar */, } + +let foo = dict{"a": 1} +let qux = dict{"c": 3} + +let x = dict{...foo, "bar": 2, ...qux} + +let x = dict{ + "before": 1, + ...foo, + "after": 2, +} + +let x = dict{ + /* before foo */ ...foo, + /* before bar */ "bar": 2, + /* before baz */ ...qux, +} + +let x = dict{ + ...foo /* after foo */, + "bar": 2, + ...qux /* after qux */, +} diff --git a/tests/tests/src/stdlib/Stdlib_DictTests.mjs b/tests/tests/src/stdlib/Stdlib_DictTests.mjs index a51a827bd60..81c196eda16 100644 --- a/tests/tests/src/stdlib/Stdlib_DictTests.mjs +++ b/tests/tests/src/stdlib/Stdlib_DictTests.mjs @@ -293,10 +293,105 @@ Test.run([ "concatAll with empty array returns empty dictionary" ], Object.assign({}), eq, {}); +let foo = { + a: 1, + b: 2 +}; + +let baz = { + b: 4, + c: 5 +}; + +let result$4 = Object.assign({}, foo, { + b: 3 +}, baz, { + d: 6 +}); + +Test.run([ + [ + "Stdlib_DictTests.res", + 189, + 15, + 63 + ], + "dict spread applies sources from left to right" +], result$4, eq, { + a: 1, + b: 4, + c: 5, + d: 6 +}); + +Test.run([ + [ + "Stdlib_DictTests.res", + 200, + 15, + 58 + ], + "dict spread leaves first source unchanged" +], foo, eq, { + a: 1, + b: 2 +}); + +Test.run([ + [ + "Stdlib_DictTests.res", + 209, + 15, + 58 + ], + "dict spread leaves later source unchanged" +], baz, eq, { + b: 4, + c: 5 +}); + +Test.run([ + [ + "Stdlib_DictTests.res", + 217, + 22, + 62 + ], + "dict spread returns a fresh dictionary" +], result$4 === foo, eq, false); + +let foo$1 = { + a: 1 +}; + +let result$5 = Object.assign({}, foo$1); + +Test.run([ + [ + "Stdlib_DictTests.res", + 225, + 15, + 51 + ], + "dict spread copies a single source" +], result$5, eq, { + a: 1 +}); + +Test.run([ + [ + "Stdlib_DictTests.res", + 231, + 15, + 75 + ], + "dict spread copies a single source into a fresh dictionary" +], result$5 === foo$1, eq, false); + Test.run([ [ "Stdlib_DictTests.res", - 178, + 239, 13, 35 ], @@ -309,7 +404,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 184, + 245, 13, 34 ], @@ -324,7 +419,7 @@ let dict = { Test.run([ [ "Stdlib_DictTests.res", - 196, + 257, 22, 38 ], @@ -334,7 +429,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 197, + 258, 22, 43 ], @@ -344,7 +439,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 198, + 259, 22, 37 ], @@ -354,7 +449,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 199, + 260, 22, 39 ], @@ -364,7 +459,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 201, + 262, 15, 51 ], diff --git a/tests/tests/src/stdlib/Stdlib_DictTests.res b/tests/tests/src/stdlib/Stdlib_DictTests.res index 9ec803edfbd..13e3f9749b3 100644 --- a/tests/tests/src/stdlib/Stdlib_DictTests.res +++ b/tests/tests/src/stdlib/Stdlib_DictTests.res @@ -174,6 +174,67 @@ Test.run( dict{}, ) +{ + let foo = dict{ + "a": 1, + "b": 2, + } + let baz = dict{ + "b": 4, + "c": 5, + } + let result = dict{...foo, "b": 3, ...baz, "d": 6} + + Test.run( + __POS_OF__("dict spread applies sources from left to right"), + result, + eq, + dict{ + "a": 1, + "b": 4, + "c": 5, + "d": 6, + }, + ) + Test.run( + __POS_OF__("dict spread leaves first source unchanged"), + foo, + eq, + dict{ + "a": 1, + "b": 2, + }, + ) + Test.run( + __POS_OF__("dict spread leaves later source unchanged"), + baz, + eq, + dict{ + "b": 4, + "c": 5, + }, + ) + Test.run(__POS_OF__("dict spread returns a fresh dictionary"), result === foo, eq, false) +} + +{ + let foo = dict{"a": 1} + let result = dict{...foo} + + Test.run( + __POS_OF__("dict spread copies a single source"), + result, + eq, + dict{"a": 1}, + ) + Test.run( + __POS_OF__("dict spread copies a single source into a fresh dictionary"), + result === foo, + eq, + false, + ) +} + Test.run( __POS_OF__("getUnsafe - existing"), Dict.fromArray([("foo", "bar")])->Dict.getUnsafe("foo"), From 465bd74b694513b589d2e8ac6b706e4b4bd5dadf Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 14:14:22 +0200 Subject: [PATCH 2/5] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c79f82ceee..b6ab93b68d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Rewatch: add `--prod` flag to `build`, `watch`, and `clean` to skip dev-dependencies and dev sources (`"type": "dev"`), enabling builds in environments where dev packages aren't installed (e.g. after `pnpm install --prod`). https://github.com/rescript-lang/rescript/pull/8347 - Add `Dict.assignMany`, `Dict.concat`, `Dict.concatMany`, `Dict.concatAll`, `Array.concatAll` to the stdlib. https://github.com/rescript-lang/rescript/pull/8364 - Implement `for...of` and `for await...of` loops. https://github.com/rescript-lang/rescript/pull/7887 +- Add support for dict spreads: `dict{...foo, "bar": 2, ...qux}`. https://github.com/rescript-lang/rescript/pull/8369 #### :bug: Bug fix From e5f5eab4a7738a2a6eb913788408a3e8c5b0f414 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 14:20:07 +0200 Subject: [PATCH 3/5] cleanup --- compiler/syntax/src/res_core.ml | 62 +++++++-------------- compiler/syntax/src/res_grammar.ml | 2 +- compiler/syntax/src/res_parsetree_viewer.ml | 28 +++------- 3 files changed, 31 insertions(+), 61 deletions(-) diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index 4b849df8ae9..349b930fd2f 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -4411,6 +4411,25 @@ and parse_dict_expr ~start_pos p = chunk_loc)) [(Asttypes.Nolabel, Ast_helper.Exp.array ~loc:chunk_loc key_value_pairs)] in + let make_dict_spread target_expr source_parts = + let spread_ident = + Ast_helper.Exp.ident ~loc ~attrs:[dict_spread_attr] + (Location.mkloc + (Longident.Ldot (Longident.Lident Primitive_modules.dict, "spread")) + loc) + in + Ast_helper.Exp.apply ~loc spread_ident + [ + (Asttypes.Nolabel, target_expr); + ( Asttypes.Nolabel, + Ast_helper.Exp.array ~loc + (List.map + (function + | `Rows rows -> make_dict_chunk rows + | `Spread spread_expr -> spread_expr) + source_parts) ); + ] + in let grouped_parts = let rec loop current_rows acc = function | [] -> @@ -4436,47 +4455,8 @@ and parse_dict_expr ~start_pos p = | [] -> make_dict_chunk ~loc_override:loc [] | [`Rows rows] -> make_dict_chunk ~loc_override:loc rows | `Rows target_rows :: source_parts -> - let spread_ident = - Ast_helper.Exp.ident ~loc ~attrs:[dict_spread_attr] - (Location.mkloc - (Longident.Ldot (Longident.Lident Primitive_modules.dict, "spread")) - loc) - in - let spread = - Ast_helper.Exp.apply ~loc spread_ident - [ - (Asttypes.Nolabel, make_dict_chunk target_rows); - ( Asttypes.Nolabel, - Ast_helper.Exp.array ~loc - (List.map - (function - | `Rows rows -> make_dict_chunk rows - | `Spread spread_expr -> spread_expr) - source_parts) ); - ] - in - spread - | source_parts -> - let spread_ident = - Ast_helper.Exp.ident ~loc ~attrs:[dict_spread_attr] - (Location.mkloc - (Longident.Ldot (Longident.Lident Primitive_modules.dict, "spread")) - loc) - in - let spread = - Ast_helper.Exp.apply ~loc spread_ident - [ - (Asttypes.Nolabel, make_dict_chunk []); - ( Asttypes.Nolabel, - Ast_helper.Exp.array ~loc - (List.map - (function - | `Rows rows -> make_dict_chunk rows - | `Spread spread_expr -> spread_expr) - source_parts) ); - ] - in - spread + make_dict_spread (make_dict_chunk target_rows) source_parts + | source_parts -> make_dict_spread (make_dict_chunk []) source_parts and parse_array_exp p = let start_pos = p.Parser.start_pos in diff --git a/compiler/syntax/src/res_grammar.ml b/compiler/syntax/src/res_grammar.ml index 9b9a9514ab5..1ab5bd48881 100644 --- a/compiler/syntax/src/res_grammar.ml +++ b/compiler/syntax/src/res_grammar.ml @@ -224,7 +224,7 @@ let is_mod_expr_start = function | _ -> false let is_dict_row_start = function - | Token.DotDotDot | Token.String _ -> true + | Token.DotDotDot | String _ -> true | _ -> false let is_record_row_start = function diff --git a/compiler/syntax/src/res_parsetree_viewer.ml b/compiler/syntax/src/res_parsetree_viewer.ml index 6c541ca15b8..04c8395bbe7 100644 --- a/compiler/syntax/src/res_parsetree_viewer.ml +++ b/compiler/syntax/src/res_parsetree_viewer.ml @@ -748,17 +748,17 @@ let is_spread_belt_array_concat expr = has_spread_attr expr.pexp_attributes | _ -> false -let collect_spread_dict_expr_parts expr = - let is_tuple_array_expr (expr : Parsetree.expression) = - let is_plain_tuple (expr : Parsetree.expression) = - match expr with - | {pexp_desc = Pexp_tuple _} -> true - | _ -> false - in +let is_tuple_array (expr : Parsetree.expression) = + let is_plain_tuple (expr : Parsetree.expression) = match expr with - | {pexp_desc = Pexp_array items} -> List.for_all is_plain_tuple items + | {pexp_desc = Pexp_tuple _} -> true | _ -> false in + match expr with + | {pexp_desc = Pexp_array items} -> List.for_all is_plain_tuple items + | _ -> false + +let collect_spread_dict_expr_parts expr = let extract_literal_dict_rows (expr : Parsetree.expression) = match expr with | { @@ -774,7 +774,7 @@ let collect_spread_dict_expr_parts expr = args = [(Nolabel, key_values)]; }; } - when is_tuple_array_expr key_values -> + when is_tuple_array key_values -> Some key_values | _ -> None in @@ -869,16 +869,6 @@ let is_rewritten_underscore_apply_sugar expr = | Pexp_ident {txt = Longident.Lident "_"} -> true | _ -> false -let is_tuple_array (expr : Parsetree.expression) = - let is_plain_tuple (expr : Parsetree.expression) = - match expr with - | {pexp_desc = Pexp_tuple _} -> true - | _ -> false - in - match expr with - | {pexp_desc = Pexp_array items} -> List.for_all is_plain_tuple items - | _ -> false - let get_jsx_prop_loc = function | Parsetree.JSXPropPunning (_, name) -> name.loc | Parsetree.JSXPropValue (name, _, value) -> From ec888f4cd869c929aed574535d8b6d9628bffe67 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 14:20:50 +0200 Subject: [PATCH 4/5] format --- tests/tests/src/stdlib/Stdlib_DictTests.res | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/tests/src/stdlib/Stdlib_DictTests.res b/tests/tests/src/stdlib/Stdlib_DictTests.res index 13e3f9749b3..462910d2271 100644 --- a/tests/tests/src/stdlib/Stdlib_DictTests.res +++ b/tests/tests/src/stdlib/Stdlib_DictTests.res @@ -221,12 +221,7 @@ Test.run( let foo = dict{"a": 1} let result = dict{...foo} - Test.run( - __POS_OF__("dict spread copies a single source"), - result, - eq, - dict{"a": 1}, - ) + Test.run(__POS_OF__("dict spread copies a single source"), result, eq, dict{"a": 1}) Test.run( __POS_OF__("dict spread copies a single source into a fresh dictionary"), result === foo, From 935e0af6df9eabc5bd9fb796813b2b8786b969c8 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 14:24:52 +0200 Subject: [PATCH 5/5] update --- tests/tests/src/stdlib/Stdlib_DictTests.mjs | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/tests/src/stdlib/Stdlib_DictTests.mjs b/tests/tests/src/stdlib/Stdlib_DictTests.mjs index 81c196eda16..95860fbeefb 100644 --- a/tests/tests/src/stdlib/Stdlib_DictTests.mjs +++ b/tests/tests/src/stdlib/Stdlib_DictTests.mjs @@ -369,9 +369,9 @@ let result$5 = Object.assign({}, foo$1); Test.run([ [ "Stdlib_DictTests.res", - 225, - 15, - 51 + 224, + 22, + 58 ], "dict spread copies a single source" ], result$5, eq, { @@ -381,7 +381,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 231, + 226, 15, 75 ], @@ -391,7 +391,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 239, + 234, 13, 35 ], @@ -404,7 +404,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 245, + 240, 13, 34 ], @@ -419,7 +419,7 @@ let dict = { Test.run([ [ "Stdlib_DictTests.res", - 257, + 252, 22, 38 ], @@ -429,7 +429,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 258, + 253, 22, 43 ], @@ -439,7 +439,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 259, + 254, 22, 37 ], @@ -449,7 +449,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 260, + 255, 22, 39 ], @@ -459,7 +459,7 @@ Test.run([ Test.run([ [ "Stdlib_DictTests.res", - 262, + 257, 15, 51 ],