From 2c0458f1283fced650bd13c12dd72b19d376f83f Mon Sep 17 00:00:00 2001 From: abs0luty Date: Tue, 11 Nov 2025 01:04:58 +0400 Subject: [PATCH 1/2] Implement record spreading in const values --- CHANGELOG.md | 8 + compiler-core/src/ast/constant.rs | 22 +- compiler-core/src/ast_folder.rs | 8 +- compiler-core/src/erlang.rs | 5 +- compiler-core/src/javascript/expression.rs | 2 + compiler-core/src/metadata/module_decoder.rs | 1 + compiler-core/src/metadata/tests.rs | 1 + compiler-core/src/parse.rs | 38 +- ...tests__const_record_spread_all_fields.snap | 358 ++++++++++++++++++ ...rse__tests__const_record_spread_basic.snap | 292 ++++++++++++++ ...arse__tests__const_record_spread_only.snap | 274 ++++++++++++++ ...ests__const_record_spread_with_module.snap | 122 ++++++ compiler-core/src/parse/tests.rs | 53 +++ compiler-core/src/type_/expression.rs | 149 +++++++- ..._const_record_spread_field_type_error.snap | 23 ++ ...tests__const_record_spread_non_record.snap | 23 ++ ...const_record_spread_nonexistent_field.snap | 24 ++ ...tests__const_record_spread_type_error.snap | 24 ++ compiler-core/src/type_/tests.rs | 109 ++++++ 19 files changed, 1524 insertions(+), 12 deletions(-) create mode 100644 compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap create mode 100644 compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap create mode 100644 compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap create mode 100644 compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap create mode 100644 compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_field_type_error.snap create mode 100644 compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_non_record.snap create mode 100644 compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_nonexistent_field.snap create mode 100644 compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_type_error.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index f441ed5d1de..52e2a81eccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,14 @@ containing scientific notation or trailing zeros (i.e. `100` and `1e2`). ([ptdewey](https://github.com/ptdewey)) +- Record update syntax now works with spreading records in type constructors + for const values: + ```gleam + const a = Foo(1, 2) + const b = Foo(..a, 3) + ``` + ([Adi Salimgereyev](https://github.com/abs0luty)) + ### Build tool - The help text displayed by `gleam dev --help`, `gleam test --help`, and diff --git a/compiler-core/src/ast/constant.rs b/compiler-core/src/ast/constant.rs index 29dd259361c..bfcd6b83dc3 100644 --- a/compiler-core/src/ast/constant.rs +++ b/compiler-core/src/ast/constant.rs @@ -40,6 +40,7 @@ pub enum Constant { module: Option<(EcoString, SrcSpan)>, name: EcoString, arguments: Vec>, + spread: Option>, tag: RecordTag, type_: T, field_map: Option, @@ -104,9 +105,12 @@ impl TypedConstant { .iter() .find_map(|element| element.find_node(byte_index)) .unwrap_or(Located::Constant(self)), - Constant::Record { arguments, .. } => arguments + Constant::Record { + arguments, spread, .. + } => arguments .iter() .find_map(|argument| argument.find_node(byte_index)) + .or_else(|| spread.as_ref().and_then(|s| s.find_node(byte_index))) .unwrap_or(Located::Constant(self)), Constant::BitArray { segments, .. } => segments .iter() @@ -156,10 +160,18 @@ impl TypedConstant { .map(|element| element.referenced_variables()) .fold(im::hashset![], im::HashSet::union), - Constant::Record { arguments, .. } => arguments - .iter() - .map(|argument| argument.value.referenced_variables()) - .fold(im::hashset![], im::HashSet::union), + Constant::Record { + arguments, spread, .. + } => { + let arg_vars = arguments + .iter() + .map(|argument| argument.value.referenced_variables()) + .fold(im::hashset![], im::HashSet::union); + match spread { + Some(spread) => arg_vars.union(spread.referenced_variables()), + None => arg_vars, + } + } Constant::BitArray { segments, .. } => segments .iter() diff --git a/compiler-core/src/ast_folder.rs b/compiler-core/src/ast_folder.rs index 3a2ee41d0f0..b8fbc30c033 100644 --- a/compiler-core/src/ast_folder.rs +++ b/compiler-core/src/ast_folder.rs @@ -972,11 +972,12 @@ pub trait UntypedConstantFolder { module, name, arguments, + spread, tag: (), type_: (), field_map: _, record_constructor: _, - } => self.fold_constant_record(location, module, name, arguments), + } => self.fold_constant_record(location, module, name, arguments, spread), Constant::BitArray { location, segments } => { self.fold_constant_bit_array(location, segments) @@ -1059,12 +1060,14 @@ pub trait UntypedConstantFolder { module: Option<(EcoString, SrcSpan)>, name: EcoString, arguments: Vec>, + spread: Option>, ) -> UntypedConstant { Constant::Record { location, module, name, arguments, + spread, tag: (), type_: (), field_map: None, @@ -1146,6 +1149,7 @@ pub trait UntypedConstantFolder { module, name, arguments, + spread, tag, type_, field_map, @@ -1158,11 +1162,13 @@ pub trait UntypedConstantFolder { argument }) .collect(); + let spread = spread.map(|s| Box::new(self.fold_constant(*s))); Constant::Record { location, module, name, arguments, + spread, tag, type_, field_map, diff --git a/compiler-core/src/erlang.rs b/compiler-core/src/erlang.rs index c7d2e73bda7..09246f005d8 100644 --- a/compiler-core/src/erlang.rs +++ b/compiler-core/src/erlang.rs @@ -1542,11 +1542,12 @@ fn const_inline<'a>(literal: &'a TypedConstant, env: &mut Env<'a>) -> Document<' }, Constant::Record { tag, arguments, .. } => { - let arguments = arguments + // Spreads are fully expanded during type checking, so we just handle arguments + let arguments_doc = arguments .iter() .map(|argument| const_inline(&argument.value, env)); let tag = atom_string(to_snake_case(tag)); - tuple(std::iter::once(tag).chain(arguments)) + tuple(std::iter::once(tag).chain(arguments_doc)) } Constant::Var { diff --git a/compiler-core/src/javascript/expression.rs b/compiler-core/src/javascript/expression.rs index 33cbb490620..152222578db 100644 --- a/compiler-core/src/javascript/expression.rs +++ b/compiler-core/src/javascript/expression.rs @@ -1839,6 +1839,7 @@ impl<'module, 'a> Generator<'module, 'a> { return record_constructor(type_.clone(), None, name, arity, self.tracker); } + // Spreads are fully expanded during type checking, so we just handle arguments let field_values = arguments .iter() .map(|argument| self.constant_expression(context, &argument.value)) @@ -2253,6 +2254,7 @@ impl<'module, 'a> Generator<'module, 'a> { return record_constructor(type_.clone(), None, name, arity, self.tracker); } + // Spreads are fully expanded during type checking, so we just handle arguments let field_values = arguments .iter() .map(|argument| self.guard_constant_expression(&argument.value)) diff --git a/compiler-core/src/metadata/module_decoder.rs b/compiler-core/src/metadata/module_decoder.rs index 765427e097e..4c1f0743f57 100755 --- a/compiler-core/src/metadata/module_decoder.rs +++ b/compiler-core/src/metadata/module_decoder.rs @@ -437,6 +437,7 @@ impl ModuleDecoder { module: Default::default(), name: Default::default(), arguments, + spread: None, tag, type_, field_map: None, diff --git a/compiler-core/src/metadata/tests.rs b/compiler-core/src/metadata/tests.rs index c93238f68f8..2b881b71c1d 100644 --- a/compiler-core/src/metadata/tests.rs +++ b/compiler-core/src/metadata/tests.rs @@ -1164,6 +1164,7 @@ fn constant_record() { }, }, ], + spread: None, tag: "thetag".into(), type_: type_::int(), field_map: None, diff --git a/compiler-core/src/parse.rs b/compiler-core/src/parse.rs index 016b62b8a24..800b32394e7 100644 --- a/compiler-core/src/parse.rs +++ b/compiler-core/src/parse.rs @@ -3310,23 +3310,54 @@ where ) -> Result, ParseError> { match self.maybe_one(&Token::LeftParen) { Some((par_s, _)) => { - let arguments = - Parser::series_of(self, &Parser::parse_const_record_arg, Some(&Token::Comma))?; + // Check for spread syntax: Record(..base, ...) + let spread = match self.maybe_one(&Token::DotDot) { + Some(_) => { + // Parse the spread target constant + let spread_value = self.parse_const_value()?; + match spread_value { + Some(value) => Some(Box::new(value)), + None => { + return parse_error( + ParseErrorType::UnexpectedEof, + SrcSpan::new(par_s, par_s + 2), + ); + } + } + } + None => None, + }; + + // Parse remaining arguments after the spread (if any) + let mut arguments = vec![]; + if (spread.is_some() && self.maybe_one(&Token::Comma).is_some()) || spread.is_none() + { + arguments = Parser::series_of( + self, + &Parser::parse_const_record_arg, + Some(&Token::Comma), + )?; + } + let (_, par_e) = self.expect_one_following_series( &Token::RightParen, "a constant record argument", )?; - if arguments.is_empty() { + + // Validate that we have either arguments or a spread + if arguments.is_empty() && spread.is_none() { return parse_error( ParseErrorType::ConstantRecordConstructorNoArguments, SrcSpan::new(par_s, par_e), ); } + Ok(Some(Constant::Record { location: SrcSpan { start, end: par_e }, module, name, arguments, + spread, tag: (), type_: (), field_map: None, @@ -3338,6 +3369,7 @@ where module, name, arguments: vec![], + spread: None, tag: (), type_: (), field_map: None, diff --git a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap new file mode 100644 index 00000000000..65e09e8075c --- /dev/null +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap @@ -0,0 +1,358 @@ +--- +source: compiler-core/src/parse/tests.rs +expression: "\ntype Person {\n Person(name: String, age: Int, city: String)\n}\n\nconst base = Person(\"Alice\", 30, \"London\")\nconst updated = Person(..base, name: \"Bob\", age: 25)\n" +--- +Parsed { + module: Module { + name: "", + documentation: [], + type_info: (), + definitions: [ + TargetedDefinition { + definition: CustomType( + CustomType { + location: SrcSpan { + start: 1, + end: 12, + }, + end_position: 63, + name: "Person", + name_location: SrcSpan { + start: 6, + end: 12, + }, + publicity: Private, + constructors: [ + RecordConstructor { + location: SrcSpan { + start: 17, + end: 61, + }, + name_location: SrcSpan { + start: 17, + end: 23, + }, + name: "Person", + arguments: [ + RecordConstructorArg { + label: Some( + ( + SrcSpan { + start: 24, + end: 28, + }, + "name", + ), + ), + ast: Constructor( + TypeAstConstructor { + location: SrcSpan { + start: 30, + end: 36, + }, + name_location: SrcSpan { + start: 30, + end: 36, + }, + module: None, + name: "String", + arguments: [], + start_parentheses: None, + }, + ), + location: SrcSpan { + start: 24, + end: 36, + }, + type_: (), + doc: None, + }, + RecordConstructorArg { + label: Some( + ( + SrcSpan { + start: 38, + end: 41, + }, + "age", + ), + ), + ast: Constructor( + TypeAstConstructor { + location: SrcSpan { + start: 43, + end: 46, + }, + name_location: SrcSpan { + start: 43, + end: 46, + }, + module: None, + name: "Int", + arguments: [], + start_parentheses: None, + }, + ), + location: SrcSpan { + start: 38, + end: 46, + }, + type_: (), + doc: None, + }, + RecordConstructorArg { + label: Some( + ( + SrcSpan { + start: 48, + end: 52, + }, + "city", + ), + ), + ast: Constructor( + TypeAstConstructor { + location: SrcSpan { + start: 54, + end: 60, + }, + name_location: SrcSpan { + start: 54, + end: 60, + }, + module: None, + name: "String", + arguments: [], + start_parentheses: None, + }, + ), + location: SrcSpan { + start: 48, + end: 60, + }, + type_: (), + doc: None, + }, + ], + documentation: None, + deprecation: NotDeprecated, + }, + ], + documentation: None, + deprecation: NotDeprecated, + opaque: false, + parameters: [], + typed_parameters: [], + external_erlang: None, + external_javascript: None, + }, + ), + target: None, + }, + TargetedDefinition { + definition: ModuleConstant( + ModuleConstant { + documentation: None, + location: SrcSpan { + start: 65, + end: 75, + }, + publicity: Private, + name: "base", + name_location: SrcSpan { + start: 71, + end: 75, + }, + annotation: None, + value: Record { + location: SrcSpan { + start: 78, + end: 107, + }, + module: None, + name: "Person", + arguments: [ + CallArg { + label: None, + location: SrcSpan { + start: 85, + end: 92, + }, + value: String { + location: SrcSpan { + start: 85, + end: 92, + }, + value: "Alice", + }, + implicit: None, + }, + CallArg { + label: None, + location: SrcSpan { + start: 94, + end: 96, + }, + value: Int { + location: SrcSpan { + start: 94, + end: 96, + }, + value: "30", + int_value: 30, + }, + implicit: None, + }, + CallArg { + label: None, + location: SrcSpan { + start: 98, + end: 106, + }, + value: String { + location: SrcSpan { + start: 98, + end: 106, + }, + value: "London", + }, + implicit: None, + }, + ], + spread: None, + tag: (), + type_: (), + field_map: None, + record_constructor: None, + }, + type_: (), + deprecation: NotDeprecated, + implementations: Implementations { + gleam: true, + can_run_on_erlang: true, + can_run_on_javascript: true, + uses_erlang_externals: false, + uses_javascript_externals: false, + }, + }, + ), + target: None, + }, + TargetedDefinition { + definition: ModuleConstant( + ModuleConstant { + documentation: None, + location: SrcSpan { + start: 108, + end: 121, + }, + publicity: Private, + name: "updated", + name_location: SrcSpan { + start: 114, + end: 121, + }, + annotation: None, + value: Record { + location: SrcSpan { + start: 124, + end: 160, + }, + module: None, + name: "Person", + arguments: [ + CallArg { + label: Some( + "name", + ), + location: SrcSpan { + start: 139, + end: 150, + }, + value: String { + location: SrcSpan { + start: 145, + end: 150, + }, + value: "Bob", + }, + implicit: None, + }, + CallArg { + label: Some( + "age", + ), + location: SrcSpan { + start: 152, + end: 159, + }, + value: Int { + location: SrcSpan { + start: 157, + end: 159, + }, + value: "25", + int_value: 25, + }, + implicit: None, + }, + ], + spread: Some( + Var { + location: SrcSpan { + start: 133, + end: 137, + }, + module: None, + name: "base", + constructor: None, + type_: (), + }, + ), + tag: (), + type_: (), + field_map: None, + record_constructor: None, + }, + type_: (), + deprecation: NotDeprecated, + implementations: Implementations { + gleam: true, + can_run_on_erlang: true, + can_run_on_javascript: true, + uses_erlang_externals: false, + uses_javascript_externals: false, + }, + }, + ), + target: None, + }, + ], + names: Names { + local_types: {}, + imported_modules: {}, + type_variables: {}, + local_value_constructors: {}, + reexport_aliases: {}, + }, + unused_definition_positions: {}, + }, + extra: ModuleExtra { + module_comments: [], + doc_comments: [], + comments: [], + empty_lines: [ + 64, + ], + new_lines: [ + 0, + 14, + 61, + 63, + 64, + 107, + 160, + ], + trailing_commas: [], + }, +} diff --git a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap new file mode 100644 index 00000000000..8d945ffacff --- /dev/null +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap @@ -0,0 +1,292 @@ +--- +source: compiler-core/src/parse/tests.rs +expression: "\ntype Person {\n Person(name: String, age: Int)\n}\n\nconst alice = Person(\"Alice\", 30)\nconst bob = Person(..alice, name: \"Bob\")\n" +--- +Parsed { + module: Module { + name: "", + documentation: [], + type_info: (), + definitions: [ + TargetedDefinition { + definition: CustomType( + CustomType { + location: SrcSpan { + start: 1, + end: 12, + }, + end_position: 49, + name: "Person", + name_location: SrcSpan { + start: 6, + end: 12, + }, + publicity: Private, + constructors: [ + RecordConstructor { + location: SrcSpan { + start: 17, + end: 47, + }, + name_location: SrcSpan { + start: 17, + end: 23, + }, + name: "Person", + arguments: [ + RecordConstructorArg { + label: Some( + ( + SrcSpan { + start: 24, + end: 28, + }, + "name", + ), + ), + ast: Constructor( + TypeAstConstructor { + location: SrcSpan { + start: 30, + end: 36, + }, + name_location: SrcSpan { + start: 30, + end: 36, + }, + module: None, + name: "String", + arguments: [], + start_parentheses: None, + }, + ), + location: SrcSpan { + start: 24, + end: 36, + }, + type_: (), + doc: None, + }, + RecordConstructorArg { + label: Some( + ( + SrcSpan { + start: 38, + end: 41, + }, + "age", + ), + ), + ast: Constructor( + TypeAstConstructor { + location: SrcSpan { + start: 43, + end: 46, + }, + name_location: SrcSpan { + start: 43, + end: 46, + }, + module: None, + name: "Int", + arguments: [], + start_parentheses: None, + }, + ), + location: SrcSpan { + start: 38, + end: 46, + }, + type_: (), + doc: None, + }, + ], + documentation: None, + deprecation: NotDeprecated, + }, + ], + documentation: None, + deprecation: NotDeprecated, + opaque: false, + parameters: [], + typed_parameters: [], + external_erlang: None, + external_javascript: None, + }, + ), + target: None, + }, + TargetedDefinition { + definition: ModuleConstant( + ModuleConstant { + documentation: None, + location: SrcSpan { + start: 51, + end: 62, + }, + publicity: Private, + name: "alice", + name_location: SrcSpan { + start: 57, + end: 62, + }, + annotation: None, + value: Record { + location: SrcSpan { + start: 65, + end: 84, + }, + module: None, + name: "Person", + arguments: [ + CallArg { + label: None, + location: SrcSpan { + start: 72, + end: 79, + }, + value: String { + location: SrcSpan { + start: 72, + end: 79, + }, + value: "Alice", + }, + implicit: None, + }, + CallArg { + label: None, + location: SrcSpan { + start: 81, + end: 83, + }, + value: Int { + location: SrcSpan { + start: 81, + end: 83, + }, + value: "30", + int_value: 30, + }, + implicit: None, + }, + ], + spread: None, + tag: (), + type_: (), + field_map: None, + record_constructor: None, + }, + type_: (), + deprecation: NotDeprecated, + implementations: Implementations { + gleam: true, + can_run_on_erlang: true, + can_run_on_javascript: true, + uses_erlang_externals: false, + uses_javascript_externals: false, + }, + }, + ), + target: None, + }, + TargetedDefinition { + definition: ModuleConstant( + ModuleConstant { + documentation: None, + location: SrcSpan { + start: 85, + end: 94, + }, + publicity: Private, + name: "bob", + name_location: SrcSpan { + start: 91, + end: 94, + }, + annotation: None, + value: Record { + location: SrcSpan { + start: 97, + end: 125, + }, + module: None, + name: "Person", + arguments: [ + CallArg { + label: Some( + "name", + ), + location: SrcSpan { + start: 113, + end: 124, + }, + value: String { + location: SrcSpan { + start: 119, + end: 124, + }, + value: "Bob", + }, + implicit: None, + }, + ], + spread: Some( + Var { + location: SrcSpan { + start: 106, + end: 111, + }, + module: None, + name: "alice", + constructor: None, + type_: (), + }, + ), + tag: (), + type_: (), + field_map: None, + record_constructor: None, + }, + type_: (), + deprecation: NotDeprecated, + implementations: Implementations { + gleam: true, + can_run_on_erlang: true, + can_run_on_javascript: true, + uses_erlang_externals: false, + uses_javascript_externals: false, + }, + }, + ), + target: None, + }, + ], + names: Names { + local_types: {}, + imported_modules: {}, + type_variables: {}, + local_value_constructors: {}, + reexport_aliases: {}, + }, + unused_definition_positions: {}, + }, + extra: ModuleExtra { + module_comments: [], + doc_comments: [], + comments: [], + empty_lines: [ + 50, + ], + new_lines: [ + 0, + 14, + 47, + 49, + 50, + 84, + 125, + ], + trailing_commas: [], + }, +} diff --git a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap new file mode 100644 index 00000000000..0d7b04ea073 --- /dev/null +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap @@ -0,0 +1,274 @@ +--- +source: compiler-core/src/parse/tests.rs +expression: "\ntype Person {\n Person(name: String, age: Int)\n}\n\nconst alice = Person(\"Alice\", 30)\nconst bob = Person(..alice)\n" +--- +Parsed { + module: Module { + name: "", + documentation: [], + type_info: (), + definitions: [ + TargetedDefinition { + definition: CustomType( + CustomType { + location: SrcSpan { + start: 1, + end: 12, + }, + end_position: 49, + name: "Person", + name_location: SrcSpan { + start: 6, + end: 12, + }, + publicity: Private, + constructors: [ + RecordConstructor { + location: SrcSpan { + start: 17, + end: 47, + }, + name_location: SrcSpan { + start: 17, + end: 23, + }, + name: "Person", + arguments: [ + RecordConstructorArg { + label: Some( + ( + SrcSpan { + start: 24, + end: 28, + }, + "name", + ), + ), + ast: Constructor( + TypeAstConstructor { + location: SrcSpan { + start: 30, + end: 36, + }, + name_location: SrcSpan { + start: 30, + end: 36, + }, + module: None, + name: "String", + arguments: [], + start_parentheses: None, + }, + ), + location: SrcSpan { + start: 24, + end: 36, + }, + type_: (), + doc: None, + }, + RecordConstructorArg { + label: Some( + ( + SrcSpan { + start: 38, + end: 41, + }, + "age", + ), + ), + ast: Constructor( + TypeAstConstructor { + location: SrcSpan { + start: 43, + end: 46, + }, + name_location: SrcSpan { + start: 43, + end: 46, + }, + module: None, + name: "Int", + arguments: [], + start_parentheses: None, + }, + ), + location: SrcSpan { + start: 38, + end: 46, + }, + type_: (), + doc: None, + }, + ], + documentation: None, + deprecation: NotDeprecated, + }, + ], + documentation: None, + deprecation: NotDeprecated, + opaque: false, + parameters: [], + typed_parameters: [], + external_erlang: None, + external_javascript: None, + }, + ), + target: None, + }, + TargetedDefinition { + definition: ModuleConstant( + ModuleConstant { + documentation: None, + location: SrcSpan { + start: 51, + end: 62, + }, + publicity: Private, + name: "alice", + name_location: SrcSpan { + start: 57, + end: 62, + }, + annotation: None, + value: Record { + location: SrcSpan { + start: 65, + end: 84, + }, + module: None, + name: "Person", + arguments: [ + CallArg { + label: None, + location: SrcSpan { + start: 72, + end: 79, + }, + value: String { + location: SrcSpan { + start: 72, + end: 79, + }, + value: "Alice", + }, + implicit: None, + }, + CallArg { + label: None, + location: SrcSpan { + start: 81, + end: 83, + }, + value: Int { + location: SrcSpan { + start: 81, + end: 83, + }, + value: "30", + int_value: 30, + }, + implicit: None, + }, + ], + spread: None, + tag: (), + type_: (), + field_map: None, + record_constructor: None, + }, + type_: (), + deprecation: NotDeprecated, + implementations: Implementations { + gleam: true, + can_run_on_erlang: true, + can_run_on_javascript: true, + uses_erlang_externals: false, + uses_javascript_externals: false, + }, + }, + ), + target: None, + }, + TargetedDefinition { + definition: ModuleConstant( + ModuleConstant { + documentation: None, + location: SrcSpan { + start: 85, + end: 94, + }, + publicity: Private, + name: "bob", + name_location: SrcSpan { + start: 91, + end: 94, + }, + annotation: None, + value: Record { + location: SrcSpan { + start: 97, + end: 112, + }, + module: None, + name: "Person", + arguments: [], + spread: Some( + Var { + location: SrcSpan { + start: 106, + end: 111, + }, + module: None, + name: "alice", + constructor: None, + type_: (), + }, + ), + tag: (), + type_: (), + field_map: None, + record_constructor: None, + }, + type_: (), + deprecation: NotDeprecated, + implementations: Implementations { + gleam: true, + can_run_on_erlang: true, + can_run_on_javascript: true, + uses_erlang_externals: false, + uses_javascript_externals: false, + }, + }, + ), + target: None, + }, + ], + names: Names { + local_types: {}, + imported_modules: {}, + type_variables: {}, + local_value_constructors: {}, + reexport_aliases: {}, + }, + unused_definition_positions: {}, + }, + extra: ModuleExtra { + module_comments: [], + doc_comments: [], + comments: [], + empty_lines: [ + 50, + ], + new_lines: [ + 0, + 14, + 47, + 49, + 50, + 84, + 112, + ], + trailing_commas: [], + }, +} diff --git a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap new file mode 100644 index 00000000000..b953bbc1158 --- /dev/null +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap @@ -0,0 +1,122 @@ +--- +source: compiler-core/src/parse/tests.rs +expression: "\nconst local_const = other.Record(..other.base, field: value)\n" +--- +Parsed { + module: Module { + name: "", + documentation: [], + type_info: (), + definitions: [ + TargetedDefinition { + definition: ModuleConstant( + ModuleConstant { + documentation: None, + location: SrcSpan { + start: 1, + end: 18, + }, + publicity: Private, + name: "local_const", + name_location: SrcSpan { + start: 7, + end: 18, + }, + annotation: None, + value: Record { + location: SrcSpan { + start: 21, + end: 61, + }, + module: Some( + ( + "other", + SrcSpan { + start: 21, + end: 26, + }, + ), + ), + name: "Record", + arguments: [ + CallArg { + label: Some( + "field", + ), + location: SrcSpan { + start: 48, + end: 60, + }, + value: Var { + location: SrcSpan { + start: 55, + end: 60, + }, + module: None, + name: "value", + constructor: None, + type_: (), + }, + implicit: None, + }, + ], + spread: Some( + Var { + location: SrcSpan { + start: 36, + end: 46, + }, + module: Some( + ( + "other", + SrcSpan { + start: 36, + end: 41, + }, + ), + ), + name: "base", + constructor: None, + type_: (), + }, + ), + tag: (), + type_: (), + field_map: None, + record_constructor: None, + }, + type_: (), + deprecation: NotDeprecated, + implementations: Implementations { + gleam: true, + can_run_on_erlang: true, + can_run_on_javascript: true, + uses_erlang_externals: false, + uses_javascript_externals: false, + }, + }, + ), + target: None, + }, + ], + names: Names { + local_types: {}, + imported_modules: {}, + type_variables: {}, + local_value_constructors: {}, + reexport_aliases: {}, + }, + unused_definition_positions: {}, + }, + extra: ModuleExtra { + module_comments: [], + doc_comments: [], + comments: [], + empty_lines: [], + new_lines: [ + 0, + 61, + ], + trailing_commas: [], + }, +} diff --git a/compiler-core/src/parse/tests.rs b/compiler-core/src/parse/tests.rs index 2e87f13b16d..2ca7f751252 100644 --- a/compiler-core/src/parse/tests.rs +++ b/compiler-core/src/parse/tests.rs @@ -1991,3 +1991,56 @@ pub fn main() { "# ); } + +// Constant Record Spread Tests + +#[test] +fn const_record_spread_basic() { + assert_parse_module!( + r#" +type Person { + Person(name: String, age: Int) +} + +const alice = Person("Alice", 30) +const bob = Person(..alice, name: "Bob") +"# + ); +} + +#[test] +fn const_record_spread_all_fields() { + assert_parse_module!( + r#" +type Person { + Person(name: String, age: Int, city: String) +} + +const base = Person("Alice", 30, "London") +const updated = Person(..base, name: "Bob", age: 25) +"# + ); +} + +#[test] +fn const_record_spread_only() { + assert_parse_module!( + r#" +type Person { + Person(name: String, age: Int) +} + +const alice = Person("Alice", 30) +const bob = Person(..alice) +"# + ); +} + +#[test] +fn const_record_spread_with_module() { + assert_parse_module!( + r#" +const local_const = other.Record(..other.base, field: value) +"# + ); +} diff --git a/compiler-core/src/type_/expression.rs b/compiler-core/src/type_/expression.rs index 1398ac9051f..dffb93b0fb1 100644 --- a/compiler-core/src/type_/expression.rs +++ b/compiler-core/src/type_/expression.rs @@ -3828,14 +3828,157 @@ impl<'a, 'b> ExprTyper<'a, 'b> { self.infer_constant_bit_array(segments, location) } + // Handle constant records with spread separately Constant::Record { module, location, name, arguments, + spread: Some(spread_const), + .. + } => { + let constructor = self.infer_value_constructor(&module, &name, &location)?; + + let (tag, field_map, _variant_index) = match &constructor.variant { + ValueConstructorVariant::Record { + name, + field_map, + variant_index, + .. + } => (name.clone(), field_map.clone(), *variant_index), + + ValueConstructorVariant::ModuleFn { .. } + | ValueConstructorVariant::LocalVariable { .. } => { + return Err(Error::NonLocalClauseGuardVariable { location, name }); + } + + ValueConstructorVariant::ModuleConstant { literal, .. } + | ValueConstructorVariant::LocalConstant { literal } => { + return Ok(literal.clone()); + } + }; + + // Type-check the spread constant + let typed_spread = self.infer_const(&None, *spread_const); + + // Unify types - the spread should have the same type as what the constructor returns + let expected_type = match constructor.type_.as_ref() { + Type::Fn { return_: ret, .. } => ret.clone(), + _ => constructor.type_.clone(), + }; + unify(expected_type.clone(), typed_spread.type_()) + .map_err(|e| convert_unify_error(e, typed_spread.location()))?; + + // Resolve the spread if it's a constant variable + let resolved_spread = match &typed_spread { + Constant::Var { + constructor: Some(value_constructor), + .. + } => match &value_constructor.variant { + ValueConstructorVariant::LocalConstant { literal } + | ValueConstructorVariant::ModuleConstant { literal, .. } => { + literal.clone() + } + _ => typed_spread, + }, + _ => typed_spread, + }; + + // Extract spread arguments + let spread_args = match resolved_spread { + Constant::Record { + arguments: spread_args, + .. + } => spread_args, + _ => { + return Err(Error::RecordUpdateInvalidConstructor { location }); + } + }; + + // Type-check explicit override arguments + let mut typed_overrides = Vec::new(); + for arg in arguments { + if arg.uses_label_shorthand() { + self.track_feature_usage(FeatureKind::LabelShorthandSyntax, arg.location); + } + + let label = arg.label.clone(); + let typed_value = self.infer_const(&None, arg.value); + + // Find expected type for this field and validate field exists + if let Some(label_name) = &label + && let Some(field_map) = &field_map + { + match field_map.fields.get(label_name) { + Some(&index) => { + if let Type::Fn { + arguments: field_types, + .. + } = constructor.type_.as_ref() + && let Some(expected_type) = field_types.get(index as usize) + { + unify(expected_type.clone(), typed_value.type_()).map_err( + |e| convert_unify_error(e, typed_value.location()), + )?; + } + } + None => { + // Field doesn't exist in the record + return Err(self.unknown_field_error( + field_map.fields.keys().cloned().collect(), + expected_type.clone(), + arg.location, + label_name.clone(), + FieldAccessUsage::Other, + )); + } + } + } + + typed_overrides.push(CallArg { + label, + value: typed_value, + location: arg.location, + implicit: arg.implicit, + }); + } + + // Merge: start with spread args, override with explicit args + let mut final_arguments = spread_args; + for override_arg in typed_overrides { + if let Some(label) = &override_arg.label + && let Some(field_map) = &field_map + && let Some(&index) = field_map.fields.get(label) + && (index as usize) < final_arguments.len() + { + *final_arguments + .get_mut(index as usize) + .expect("Index out of bounds") = override_arg; + } + } + + Ok(Constant::Record { + module, + location, + name, + arguments: final_arguments, + spread: None, + type_: expected_type, + tag, + field_map, + record_constructor: Some(Box::new(constructor)), + }) + } + + Constant::Record { + module, + location, + name, + arguments, + spread, // field_map, is always None here because untyped not yet unified .. - } if arguments.is_empty() => { + } if arguments.is_empty() && spread.is_none() => { // Type check the record constructor let constructor = self.infer_value_constructor(&module, &name, &location)?; @@ -3861,6 +4004,7 @@ impl<'a, 'b> ExprTyper<'a, 'b> { location, name, arguments: vec![], + spread: None, type_: constructor.type_.clone(), tag, field_map, @@ -3868,11 +4012,13 @@ impl<'a, 'b> ExprTyper<'a, 'b> { }) } + // Handle constant records without spread (normal case) Constant::Record { module, location, name, mut arguments, + spread: None, // field_map, is always None here because untyped not yet unified .. } => { @@ -4004,6 +4150,7 @@ impl<'a, 'b> ExprTyper<'a, 'b> { location, name, arguments, + spread: None, type_: return_type, tag, field_map, diff --git a/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_field_type_error.snap b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_field_type_error.snap new file mode 100644 index 00000000000..570b610006f --- /dev/null +++ b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_field_type_error.snap @@ -0,0 +1,23 @@ +--- +source: compiler-core/src/type_/tests.rs +expression: "pub type Person { Person(name: String, age: Int) }\n pub const alice = Person(\"Alice\", 30)\n pub const bob = Person(..alice, age: \"not a number\")" +--- +----- SOURCE CODE +pub type Person { Person(name: String, age: Int) } + pub const alice = Person("Alice", 30) + pub const bob = Person(..alice, age: "not a number") + +----- ERROR +error: Type mismatch + ┌─ /src/one/two.gleam:3:46 + │ +3 │ pub const bob = Person(..alice, age: "not a number") + │ ^^^^^^^^^^^^^^ + +Expected type: + + Int + +Found type: + + String diff --git a/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_non_record.snap b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_non_record.snap new file mode 100644 index 00000000000..1fe30d83841 --- /dev/null +++ b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_non_record.snap @@ -0,0 +1,23 @@ +--- +source: compiler-core/src/type_/tests.rs +expression: "pub type Person { Person(name: String, age: Int) }\n pub const not_a_record = 42\n pub const bob = Person(..not_a_record, name: \"Bob\")" +--- +----- SOURCE CODE +pub type Person { Person(name: String, age: Int) } + pub const not_a_record = 42 + pub const bob = Person(..not_a_record, name: "Bob") + +----- ERROR +error: Type mismatch + ┌─ /src/one/two.gleam:3:34 + │ +3 │ pub const bob = Person(..not_a_record, name: "Bob") + │ ^^^^^^^^^^^^ + +Expected type: + + Person + +Found type: + + Int diff --git a/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_nonexistent_field.snap b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_nonexistent_field.snap new file mode 100644 index 00000000000..d74cfbe90de --- /dev/null +++ b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_nonexistent_field.snap @@ -0,0 +1,24 @@ +--- +source: compiler-core/src/type_/tests.rs +expression: "pub type Person { Person(name: String, age: Int) }\n pub const alice = Person(\"Alice\", 30)\n pub const bob = Person(..alice, nonexistent: \"value\")" +--- +----- SOURCE CODE +pub type Person { Person(name: String, age: Int) } + pub const alice = Person("Alice", 30) + pub const bob = Person(..alice, nonexistent: "value") + +----- ERROR +error: Unknown record field + ┌─ /src/one/two.gleam:3:41 + │ +3 │ pub const bob = Person(..alice, nonexistent: "value") + │ ^^^^^^^^^^^^^^^^^^^^ This field does not exist + +The value being accessed has this type: + + Person + +It has these accessible fields: + + .age + .name diff --git a/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_type_error.snap b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_type_error.snap new file mode 100644 index 00000000000..0edc189aa65 --- /dev/null +++ b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_type_error.snap @@ -0,0 +1,24 @@ +--- +source: compiler-core/src/type_/tests.rs +expression: "pub type Person { Person(name: String, age: Int) }\n pub type Animal { Animal(species: String) }\n pub const alice = Person(\"Alice\", 30)\n pub const dog = Animal(..alice)" +--- +----- SOURCE CODE +pub type Person { Person(name: String, age: Int) } + pub type Animal { Animal(species: String) } + pub const alice = Person("Alice", 30) + pub const dog = Animal(..alice) + +----- ERROR +error: Type mismatch + ┌─ /src/one/two.gleam:4:34 + │ +4 │ pub const dog = Animal(..alice) + │ ^^^^^ + +Expected type: + + Animal + +Found type: + + Person diff --git a/compiler-core/src/type_/tests.rs b/compiler-core/src/type_/tests.rs index bfcc5d0b7f9..e15c475c6ca 100644 --- a/compiler-core/src/type_/tests.rs +++ b/compiler-core/src/type_/tests.rs @@ -2305,6 +2305,115 @@ fn custom_type_module_constants() { ); } +#[test] +fn const_record_spread_basic() { + assert_module_infer!( + "pub type Person { Person(name: String, age: Int) } + pub const alice = Person(\"Alice\", 30) + pub const bob = Person(..alice, name: \"Bob\")", + vec![ + ("Person", "fn(String, Int) -> Person"), + ("alice", "Person"), + ("bob", "Person") + ], + ); +} + +#[test] +fn const_record_spread_all_fields() { + assert_module_infer!( + "pub type Person { Person(name: String, age: Int, city: String) } + pub const base = Person(\"Alice\", 30, \"London\") + pub const updated = Person(..base, name: \"Bob\", age: 25)", + vec![ + ("Person", "fn(String, Int, String) -> Person"), + ("base", "Person"), + ("updated", "Person") + ], + ); +} + +#[test] +fn const_record_spread_only() { + assert_module_infer!( + "pub type Person { Person(name: String, age: Int) } + pub const alice = Person(\"Alice\", 30) + pub const bob = Person(..alice)", + vec![ + ("Person", "fn(String, Int) -> Person"), + ("alice", "Person"), + ("bob", "Person") + ], + ); +} + +#[test] +fn const_record_spread_chain() { + assert_module_infer!( + "pub type Person { Person(name: String, age: Int, city: String) } + pub const alice = Person(\"Alice\", 30, \"London\") + pub const bob = Person(..alice, name: \"Bob\") + pub const charlie = Person(..bob, age: 25)", + vec![ + ("Person", "fn(String, Int, String) -> Person"), + ("alice", "Person"), + ("bob", "Person"), + ("charlie", "Person") + ], + ); +} + +#[test] +fn const_record_spread_with_labeled_args() { + assert_module_infer!( + "pub type Person { Person(name: String, age: Int, city: String) } + pub const alice = Person(name: \"Alice\", age: 30, city: \"London\") + pub const bob = Person(..alice, name: \"Bob\")", + vec![ + ("Person", "fn(String, Int, String) -> Person"), + ("alice", "Person"), + ("bob", "Person") + ], + ); +} + +#[test] +fn const_record_spread_type_error() { + assert_module_error!( + "pub type Person { Person(name: String, age: Int) } + pub type Animal { Animal(species: String) } + pub const alice = Person(\"Alice\", 30) + pub const dog = Animal(..alice)" + ); +} + +#[test] +fn const_record_spread_field_type_error() { + assert_module_error!( + "pub type Person { Person(name: String, age: Int) } + pub const alice = Person(\"Alice\", 30) + pub const bob = Person(..alice, age: \"not a number\")" + ); +} + +#[test] +fn const_record_spread_nonexistent_field() { + assert_module_error!( + "pub type Person { Person(name: String, age: Int) } + pub const alice = Person(\"Alice\", 30) + pub const bob = Person(..alice, nonexistent: \"value\")" + ); +} + +#[test] +fn const_record_spread_non_record() { + assert_module_error!( + "pub type Person { Person(name: String, age: Int) } + pub const not_a_record = 42 + pub const bob = Person(..not_a_record, name: \"Bob\")" + ); +} + #[test] fn module_constant_functions() { assert_module_infer!( From 5f48b6db18ca4b17fc34d02e518f5450b0013b3b Mon Sep 17 00:00:00 2001 From: abs0luty Date: Tue, 11 Nov 2025 14:11:35 +0400 Subject: [PATCH 2/2] Add tests and do some refactoring --- compiler-core/src/ast.rs | 2 +- compiler-core/src/ast/constant.rs | 71 +++++-- compiler-core/src/ast_folder.rs | 88 +++++++-- compiler-core/src/call_graph.rs | 11 ++ compiler-core/src/erlang.rs | 13 ++ compiler-core/src/format.rs | 6 + compiler-core/src/javascript/expression.rs | 6 + compiler-core/src/metadata/module_decoder.rs | 1 - compiler-core/src/metadata/module_encoder.rs | 6 + compiler-core/src/metadata/tests.rs | 1 - compiler-core/src/parse.rs | 185 +++++++++++++----- ...tests__const_record_spread_all_fields.snap | 40 ++-- ...rse__tests__const_record_spread_basic.snap | 33 ++-- ...arse__tests__const_record_spread_only.snap | 26 ++- ...ests__const_record_spread_with_module.snap | 48 ++--- compiler-core/src/type_/expression.rs | 127 +++++++----- ...d_spread_variant_without_args_warning.snap | 17 ++ compiler-core/src/type_/tests.rs | 72 +++++++ 18 files changed, 541 insertions(+), 212 deletions(-) create mode 100644 compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_variant_without_args_warning.snap diff --git a/compiler-core/src/ast.rs b/compiler-core/src/ast.rs index f3d9b42ab98..c0da6cf13b4 100644 --- a/compiler-core/src/ast.rs +++ b/compiler-core/src/ast.rs @@ -9,7 +9,7 @@ pub mod visit; pub use self::typed::{InvalidExpression, TypedExpr}; pub use self::untyped::{FunctionLiteralKind, UntypedExpr}; -pub use self::constant::{Constant, TypedConstant, UntypedConstant}; +pub use self::constant::{Constant, ConstantRecordUpdateArg, TypedConstant, UntypedConstant}; use crate::analyse::Inferred; use crate::bit_array; diff --git a/compiler-core/src/ast/constant.rs b/compiler-core/src/ast/constant.rs index bfcd6b83dc3..d4f0c1d2d3a 100644 --- a/compiler-core/src/ast/constant.rs +++ b/compiler-core/src/ast/constant.rs @@ -40,13 +40,23 @@ pub enum Constant { module: Option<(EcoString, SrcSpan)>, name: EcoString, arguments: Vec>, - spread: Option>, tag: RecordTag, type_: T, field_map: Option, record_constructor: Option>, }, + RecordUpdate { + location: SrcSpan, + module: Option<(EcoString, SrcSpan)>, + name: EcoString, + record: Box, + arguments: Vec>, + tag: RecordTag, + type_: T, + field_map: Option, + }, + BitArray { location: SrcSpan, segments: Vec>, @@ -86,6 +96,7 @@ impl TypedConstant { } Constant::List { type_, .. } | Constant::Record { type_, .. } + | Constant::RecordUpdate { type_, .. } | Constant::Var { type_, .. } | Constant::Invalid { type_, .. } => type_.clone(), } @@ -105,12 +116,19 @@ impl TypedConstant { .iter() .find_map(|element| element.find_node(byte_index)) .unwrap_or(Located::Constant(self)), - Constant::Record { - arguments, spread, .. - } => arguments + Constant::Record { arguments, .. } => arguments .iter() .find_map(|argument| argument.find_node(byte_index)) - .or_else(|| spread.as_ref().and_then(|s| s.find_node(byte_index))) + .unwrap_or(Located::Constant(self)), + Constant::RecordUpdate { + record, arguments, .. + } => record + .find_node(byte_index) + .or_else(|| { + arguments + .iter() + .find_map(|arg| arg.value.find_node(byte_index)) + }) .unwrap_or(Located::Constant(self)), Constant::BitArray { segments, .. } => segments .iter() @@ -143,6 +161,7 @@ impl TypedConstant { } => value_constructor .as_ref() .map(|constructor| constructor.definition_location()), + Constant::RecordUpdate { .. } => None, } } @@ -160,17 +179,20 @@ impl TypedConstant { .map(|element| element.referenced_variables()) .fold(im::hashset![], im::HashSet::union), - Constant::Record { - arguments, spread, .. + Constant::Record { arguments, .. } => arguments + .iter() + .map(|argument| argument.value.referenced_variables()) + .fold(im::hashset![], im::HashSet::union), + + Constant::RecordUpdate { + record, arguments, .. } => { + let record_vars = record.referenced_variables(); let arg_vars = arguments .iter() - .map(|argument| argument.value.referenced_variables()) + .map(|arg| arg.value.referenced_variables()) .fold(im::hashset![], im::HashSet::union); - match spread { - Some(spread) => arg_vars.union(spread.referenced_variables()), - None => arg_vars, - } + record_vars.union(arg_vars) } Constant::BitArray { segments, .. } => segments @@ -206,6 +228,7 @@ impl Constant { | Constant::Tuple { location, .. } | Constant::String { location, .. } | Constant::Record { location, .. } + | Constant::RecordUpdate { location, .. } | Constant::BitArray { location, .. } | Constant::Var { location, .. } | Constant::Invalid { location, .. } @@ -224,6 +247,7 @@ impl Constant { Constant::Tuple { .. } | Constant::List { .. } | Constant::Record { .. } + | Constant::RecordUpdate { .. } | Constant::BitArray { .. } | Constant::StringConcatenation { .. } | Constant::Invalid { .. } => false, @@ -246,3 +270,26 @@ impl bit_array::GetLiteralValue for Constant { } } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConstantRecordUpdateArg { + pub label: EcoString, + pub location: SrcSpan, + pub value: Constant, +} + +impl ConstantRecordUpdateArg { + #[must_use] + pub fn uses_label_shorthand(&self) -> bool + where + Constant: HasLocation, + { + self.value.location() == self.location + } +} + +impl HasLocation for ConstantRecordUpdateArg { + fn location(&self) -> SrcSpan { + self.location + } +} diff --git a/compiler-core/src/ast_folder.rs b/compiler-core/src/ast_folder.rs index b8fbc30c033..8f3e0fd37c3 100644 --- a/compiler-core/src/ast_folder.rs +++ b/compiler-core/src/ast_folder.rs @@ -6,15 +6,15 @@ use vec1::Vec1; use crate::{ analyse::Inferred, ast::{ - Assert, AssignName, Assignment, BinOp, BitArraySize, CallArg, Constant, Definition, - FunctionLiteralKind, Pattern, RecordBeingUpdated, SrcSpan, Statement, TailPattern, - TargetedDefinition, TodoKind, TypeAst, TypeAstConstructor, TypeAstFn, TypeAstHole, - TypeAstTuple, TypeAstVar, UntypedArg, UntypedAssert, UntypedAssignment, UntypedClause, - UntypedConstant, UntypedConstantBitArraySegment, UntypedCustomType, UntypedDefinition, - UntypedExpr, UntypedExprBitArraySegment, UntypedFunction, UntypedImport, UntypedModule, - UntypedModuleConstant, UntypedPattern, UntypedPatternBitArraySegment, - UntypedRecordUpdateArg, UntypedStatement, UntypedTailPattern, UntypedTypeAlias, UntypedUse, - UntypedUseAssignment, Use, UseAssignment, + Assert, AssignName, Assignment, BinOp, BitArraySize, CallArg, Constant, + ConstantRecordUpdateArg, Definition, FunctionLiteralKind, Pattern, RecordBeingUpdated, + SrcSpan, Statement, TailPattern, TargetedDefinition, TodoKind, TypeAst, TypeAstConstructor, + TypeAstFn, TypeAstHole, TypeAstTuple, TypeAstVar, UntypedArg, UntypedAssert, + UntypedAssignment, UntypedClause, UntypedConstant, UntypedConstantBitArraySegment, + UntypedCustomType, UntypedDefinition, UntypedExpr, UntypedExprBitArraySegment, + UntypedFunction, UntypedImport, UntypedModule, UntypedModuleConstant, UntypedPattern, + UntypedPatternBitArraySegment, UntypedRecordUpdateArg, UntypedStatement, + UntypedTailPattern, UntypedTypeAlias, UntypedUse, UntypedUseAssignment, Use, UseAssignment, }, build::Target, parse::LiteralFloatValue, @@ -972,12 +972,22 @@ pub trait UntypedConstantFolder { module, name, arguments, - spread, tag: (), type_: (), field_map: _, record_constructor: _, - } => self.fold_constant_record(location, module, name, arguments, spread), + } => self.fold_constant_record(location, module, name, arguments), + + Constant::RecordUpdate { + location, + module, + name, + record, + arguments, + tag: (), + type_: (), + field_map: _, + } => self.fold_constant_record_update(location, module, name, record, arguments), Constant::BitArray { location, segments } => { self.fold_constant_bit_array(location, segments) @@ -1060,14 +1070,12 @@ pub trait UntypedConstantFolder { module: Option<(EcoString, SrcSpan)>, name: EcoString, arguments: Vec>, - spread: Option>, ) -> UntypedConstant { Constant::Record { location, module, name, arguments, - spread, tag: (), type_: (), field_map: None, @@ -1075,6 +1083,26 @@ pub trait UntypedConstantFolder { } } + fn fold_constant_record_update( + &mut self, + location: SrcSpan, + module: Option<(EcoString, SrcSpan)>, + name: EcoString, + record: Box, + arguments: Vec>, + ) -> UntypedConstant { + Constant::RecordUpdate { + location, + module, + name, + record, + arguments, + tag: (), + type_: (), + field_map: None, + } + } + fn fold_constant_bit_array( &mut self, location: SrcSpan, @@ -1149,7 +1177,6 @@ pub trait UntypedConstantFolder { module, name, arguments, - spread, tag, type_, field_map, @@ -1162,13 +1189,11 @@ pub trait UntypedConstantFolder { argument }) .collect(); - let spread = spread.map(|s| Box::new(self.fold_constant(*s))); Constant::Record { location, module, name, arguments, - spread, tag, type_, field_map, @@ -1176,6 +1201,37 @@ pub trait UntypedConstantFolder { } } + Constant::RecordUpdate { + location, + module, + name, + record, + arguments, + tag, + type_, + field_map, + } => { + let record = Box::new(self.fold_constant(*record)); + let arguments = arguments + .into_iter() + .map(|arg| ConstantRecordUpdateArg { + label: arg.label, + location: arg.location, + value: self.fold_constant(arg.value), + }) + .collect(); + Constant::RecordUpdate { + location, + module, + name, + record, + arguments, + tag, + type_, + field_map, + } + } + Constant::BitArray { location, segments } => { let segments = segments .into_iter() diff --git a/compiler-core/src/call_graph.rs b/compiler-core/src/call_graph.rs index 32ba2695270..e036996b7ad 100644 --- a/compiler-core/src/call_graph.rs +++ b/compiler-core/src/call_graph.rs @@ -494,6 +494,17 @@ impl<'a> CallGraphBuilder<'a> { } } + Constant::RecordUpdate { + record, arguments, .. + } => { + // Visit the record being updated + self.constant(record); + // Visit the update arguments + for argument in arguments { + self.constant(&argument.value); + } + } + Constant::Var { module: None, name, .. } => self.referenced(name), diff --git a/compiler-core/src/erlang.rs b/compiler-core/src/erlang.rs index 09246f005d8..9a4342dab32 100644 --- a/compiler-core/src/erlang.rs +++ b/compiler-core/src/erlang.rs @@ -1550,6 +1550,13 @@ fn const_inline<'a>(literal: &'a TypedConstant, env: &mut Env<'a>) -> Document<' tuple(std::iter::once(tag).chain(arguments_doc)) } + Constant::RecordUpdate { .. } => { + // RecordUpdate should be expanded to Record during type checking, so this should never happen + panic!( + "Encountered RecordUpdate in code generation - this should have been expanded during type checking" + ) + } + Constant::Var { name, constructor, .. } => var( @@ -3306,6 +3313,12 @@ fn find_referenced_private_functions( .iter() .for_each(|argument| find_referenced_private_functions(&argument.value, already_found)), + TypedConstant::RecordUpdate { .. } => { + panic!( + "Encountered RecordUpdate in code generation - this should have been expanded during type checking" + ) + } + TypedConstant::StringConcatenation { left, right, .. } => { find_referenced_private_functions(left, already_found); find_referenced_private_functions(right, already_found); diff --git a/compiler-core/src/format.rs b/compiler-core/src/format.rs index 63e68d6044f..4856ed3948c 100644 --- a/compiler-core/src/format.rs +++ b/compiler-core/src/format.rs @@ -539,6 +539,12 @@ impl<'comments> Formatter<'comments> { .group() } + Constant::RecordUpdate { .. } => { + panic!( + "Encountered RecordUpdate in code generation - this should have been expanded during type checking" + ) + } + Constant::Var { name, module: None, .. } => name.to_doc(), diff --git a/compiler-core/src/javascript/expression.rs b/compiler-core/src/javascript/expression.rs index 152222578db..f08749b1b06 100644 --- a/compiler-core/src/javascript/expression.rs +++ b/compiler-core/src/javascript/expression.rs @@ -1856,6 +1856,12 @@ impl<'module, 'a> Generator<'module, 'a> { } } + Constant::RecordUpdate { .. } => { + panic!( + "Encountered RecordUpdate in code generation - this should have been expanded during type checking" + ) + } + Constant::BitArray { segments, .. } => { let bit_array = self.constant_bit_array(segments, context); match context { diff --git a/compiler-core/src/metadata/module_decoder.rs b/compiler-core/src/metadata/module_decoder.rs index 4c1f0743f57..765427e097e 100755 --- a/compiler-core/src/metadata/module_decoder.rs +++ b/compiler-core/src/metadata/module_decoder.rs @@ -437,7 +437,6 @@ impl ModuleDecoder { module: Default::default(), name: Default::default(), arguments, - spread: None, tag, type_, field_map: None, diff --git a/compiler-core/src/metadata/module_encoder.rs b/compiler-core/src/metadata/module_encoder.rs index 9731c0a258f..7aad0d4e6f2 100644 --- a/compiler-core/src/metadata/module_encoder.rs +++ b/compiler-core/src/metadata/module_encoder.rs @@ -541,6 +541,12 @@ impl<'a> ModuleEncoder<'a> { self.build_type(builder.reborrow().init_type(), type_); } + Constant::RecordUpdate { .. } => { + panic!( + "Encountered RecordUpdate in code generation - this should have been expanded during type checking" + ) + } + Constant::Var { module, name, diff --git a/compiler-core/src/metadata/tests.rs b/compiler-core/src/metadata/tests.rs index 2b881b71c1d..c93238f68f8 100644 --- a/compiler-core/src/metadata/tests.rs +++ b/compiler-core/src/metadata/tests.rs @@ -1164,7 +1164,6 @@ fn constant_record() { }, }, ], - spread: None, tag: "thetag".into(), type_: type_::int(), field_map: None, diff --git a/compiler-core/src/parse.rs b/compiler-core/src/parse.rs index 800b32394e7..b4f5d419f55 100644 --- a/compiler-core/src/parse.rs +++ b/compiler-core/src/parse.rs @@ -59,11 +59,11 @@ use crate::analyse::Inferred; use crate::ast::{ Arg, ArgNames, Assert, AssignName, Assignment, AssignmentKind, BinOp, BitArrayOption, BitArraySegment, BitArraySize, CAPTURE_VARIABLE, CallArg, Clause, ClauseGuard, Constant, - CustomType, Definition, Function, FunctionLiteralKind, HasLocation, Import, IntOperator, - Module, ModuleConstant, Pattern, Publicity, RecordBeingUpdated, RecordConstructor, - RecordConstructorArg, SrcSpan, Statement, TailPattern, TargetedDefinition, TodoKind, TypeAlias, - TypeAst, TypeAstConstructor, TypeAstFn, TypeAstHole, TypeAstTuple, TypeAstVar, - UnqualifiedImport, UntypedArg, UntypedClause, UntypedClauseGuard, UntypedConstant, + ConstantRecordUpdateArg, CustomType, Definition, Function, FunctionLiteralKind, HasLocation, + Import, IntOperator, Module, ModuleConstant, Pattern, Publicity, RecordBeingUpdated, + RecordConstructor, RecordConstructorArg, SrcSpan, Statement, TailPattern, TargetedDefinition, + TodoKind, TypeAlias, TypeAst, TypeAstConstructor, TypeAstFn, TypeAstHole, TypeAstTuple, + TypeAstVar, UnqualifiedImport, UntypedArg, UntypedClause, UntypedClauseGuard, UntypedConstant, UntypedDefinition, UntypedExpr, UntypedModule, UntypedPattern, UntypedRecordUpdateArg, UntypedStatement, UntypedUseAssignment, Use, UseAssignment, }; @@ -3311,65 +3311,82 @@ where match self.maybe_one(&Token::LeftParen) { Some((par_s, _)) => { // Check for spread syntax: Record(..base, ...) - let spread = match self.maybe_one(&Token::DotDot) { - Some(_) => { - // Parse the spread target constant - let spread_value = self.parse_const_value()?; - match spread_value { - Some(value) => Some(Box::new(value)), - None => { - return parse_error( - ParseErrorType::UnexpectedEof, - SrcSpan::new(par_s, par_s + 2), - ); - } + if self.maybe_one(&Token::DotDot).is_some() { + // This is a record update: Record(..base, field: value, ...) + + // Parse the spread target constant + let record = match self.parse_const_value()? { + Some(value) => Box::new(value), + None => { + return parse_error( + ParseErrorType::UnexpectedEof, + SrcSpan::new(par_s, par_s + 2), + ); } + }; + + // Parse update arguments (field: value pairs) after optional comma + let mut update_arguments = vec![]; + if self.maybe_one(&Token::Comma).is_some() { + update_arguments = Parser::series_of( + self, + &Parser::parse_const_record_update_arg, + Some(&Token::Comma), + )?; } - None => None, - }; - // Parse remaining arguments after the spread (if any) - let mut arguments = vec![]; - if (spread.is_some() && self.maybe_one(&Token::Comma).is_some()) || spread.is_none() - { - arguments = Parser::series_of( + let (_, par_e) = self.expect_one_following_series( + &Token::RightParen, + "a constant record update argument", + )?; + + Ok(Some(Constant::RecordUpdate { + location: SrcSpan { start, end: par_e }, + module, + name, + record, + arguments: update_arguments, + tag: (), + type_: (), + field_map: None, + })) + } else { + // No spread - parse as regular Record construction + let arguments = Parser::series_of( self, &Parser::parse_const_record_arg, Some(&Token::Comma), )?; - } - let (_, par_e) = self.expect_one_following_series( - &Token::RightParen, - "a constant record argument", - )?; + let (_, par_e) = self.expect_one_following_series( + &Token::RightParen, + "a constant record argument", + )?; - // Validate that we have either arguments or a spread - if arguments.is_empty() && spread.is_none() { - return parse_error( - ParseErrorType::ConstantRecordConstructorNoArguments, - SrcSpan::new(par_s, par_e), - ); - } + if arguments.is_empty() { + return parse_error( + ParseErrorType::ConstantRecordConstructorNoArguments, + SrcSpan::new(par_s, par_e), + ); + } - Ok(Some(Constant::Record { - location: SrcSpan { start, end: par_e }, - module, - name, - arguments, - spread, - tag: (), - type_: (), - field_map: None, - record_constructor: None, - })) + Ok(Some(Constant::Record { + location: SrcSpan { start, end: par_e }, + module, + name, + arguments, + tag: (), + type_: (), + field_map: None, + record_constructor: None, + })) + } } _ => Ok(Some(Constant::Record { location: SrcSpan { start, end }, module, name, arguments: vec![], - spread: None, tag: (), type_: (), field_map: None, @@ -3440,6 +3457,78 @@ where } } + fn parse_const_record_update_arg( + &mut self, + ) -> Result>, ParseError> { + let (start, label, label_end) = match (self.tok0.take(), self.tok1.take()) { + // Named arg - required for record updates + (Some((start, Token::Name { name }, _)), Some((_, Token::Colon, end))) => { + self.advance(); + self.advance(); + (start, name, end) + } + + // Unnamed arg or other - return error since record updates require labels + (Some((start, Token::Name { name }, end)), t1) => { + self.tok0 = Some((start, Token::Name { name: name.clone() }, end)); + self.tok1 = t1; + + // Check if this is label shorthand (name without colon) + // In this case, use the name as both label and value + match self.parse_const_value()? { + Some(value) if value.location() == SrcSpan { start, end } => { + return Ok(Some(ConstantRecordUpdateArg { + label: name.clone(), + location: SrcSpan { start, end }, + value, + })); + } + _ => { + self.tok0 = Some((start, Token::Name { name }, end)); + return parse_error(ParseErrorType::ExpectedName, SrcSpan { start, end }); + } + } + } + + (t0, t1) => { + self.tok0 = t0; + self.tok1 = t1; + return Ok(None); + } + }; + + match self.parse_const_value()? { + Some(value) => Ok(Some(ConstantRecordUpdateArg { + label, + location: SrcSpan { + start, + end: value.location().end, + }, + value, + })), + _ => { + // Label shorthand: field without value means field: field + Ok(Some(ConstantRecordUpdateArg { + label: label.clone(), + location: SrcSpan { + start, + end: label_end, + }, + value: UntypedConstant::Var { + location: SrcSpan { + start, + end: label_end, + }, + constructor: None, + module: None, + name: label, + type_: (), + }, + })) + } + } + } + // // Bit String parsing // diff --git a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap index 65e09e8075c..2e17684b3f0 100644 --- a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap @@ -219,7 +219,6 @@ Parsed { implicit: None, }, ], - spread: None, tag: (), type_: (), field_map: None, @@ -253,18 +252,26 @@ Parsed { end: 121, }, annotation: None, - value: Record { + value: RecordUpdate { location: SrcSpan { start: 124, end: 160, }, module: None, name: "Person", + record: Var { + location: SrcSpan { + start: 133, + end: 137, + }, + module: None, + name: "base", + constructor: None, + type_: (), + }, arguments: [ - CallArg { - label: Some( - "name", - ), + ConstantRecordUpdateArg { + label: "name", location: SrcSpan { start: 139, end: 150, @@ -276,12 +283,9 @@ Parsed { }, value: "Bob", }, - implicit: None, }, - CallArg { - label: Some( - "age", - ), + ConstantRecordUpdateArg { + label: "age", location: SrcSpan { start: 152, end: 159, @@ -294,25 +298,11 @@ Parsed { value: "25", int_value: 25, }, - implicit: None, }, ], - spread: Some( - Var { - location: SrcSpan { - start: 133, - end: 137, - }, - module: None, - name: "base", - constructor: None, - type_: (), - }, - ), tag: (), type_: (), field_map: None, - record_constructor: None, }, type_: (), deprecation: NotDeprecated, diff --git a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap index 8d945ffacff..a34cd9ae27e 100644 --- a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap @@ -171,7 +171,6 @@ Parsed { implicit: None, }, ], - spread: None, tag: (), type_: (), field_map: None, @@ -205,18 +204,26 @@ Parsed { end: 94, }, annotation: None, - value: Record { + value: RecordUpdate { location: SrcSpan { start: 97, end: 125, }, module: None, name: "Person", + record: Var { + location: SrcSpan { + start: 106, + end: 111, + }, + module: None, + name: "alice", + constructor: None, + type_: (), + }, arguments: [ - CallArg { - label: Some( - "name", - ), + ConstantRecordUpdateArg { + label: "name", location: SrcSpan { start: 113, end: 124, @@ -228,25 +235,11 @@ Parsed { }, value: "Bob", }, - implicit: None, }, ], - spread: Some( - Var { - location: SrcSpan { - start: 106, - end: 111, - }, - module: None, - name: "alice", - constructor: None, - type_: (), - }, - ), tag: (), type_: (), field_map: None, - record_constructor: None, }, type_: (), deprecation: NotDeprecated, diff --git a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap index 0d7b04ea073..625bb84dfbe 100644 --- a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap @@ -171,7 +171,6 @@ Parsed { implicit: None, }, ], - spread: None, tag: (), type_: (), field_map: None, @@ -205,30 +204,27 @@ Parsed { end: 94, }, annotation: None, - value: Record { + value: RecordUpdate { location: SrcSpan { start: 97, end: 112, }, module: None, name: "Person", - arguments: [], - spread: Some( - Var { - location: SrcSpan { - start: 106, - end: 111, - }, - module: None, - name: "alice", - constructor: None, - type_: (), + record: Var { + location: SrcSpan { + start: 106, + end: 111, }, - ), + module: None, + name: "alice", + constructor: None, + type_: (), + }, + arguments: [], tag: (), type_: (), field_map: None, - record_constructor: None, }, type_: (), deprecation: NotDeprecated, diff --git a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap index b953bbc1158..6f9ec7b2320 100644 --- a/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap @@ -23,7 +23,7 @@ Parsed { end: 18, }, annotation: None, - value: Record { + value: RecordUpdate { location: SrcSpan { start: 21, end: 61, @@ -38,11 +38,27 @@ Parsed { ), ), name: "Record", - arguments: [ - CallArg { - label: Some( - "field", + record: Var { + location: SrcSpan { + start: 36, + end: 46, + }, + module: Some( + ( + "other", + SrcSpan { + start: 36, + end: 41, + }, ), + ), + name: "base", + constructor: None, + type_: (), + }, + arguments: [ + ConstantRecordUpdateArg { + label: "field", location: SrcSpan { start: 48, end: 60, @@ -57,33 +73,11 @@ Parsed { constructor: None, type_: (), }, - implicit: None, }, ], - spread: Some( - Var { - location: SrcSpan { - start: 36, - end: 46, - }, - module: Some( - ( - "other", - SrcSpan { - start: 36, - end: 41, - }, - ), - ), - name: "base", - constructor: None, - type_: (), - }, - ), tag: (), type_: (), field_map: None, - record_constructor: None, }, type_: (), deprecation: NotDeprecated, diff --git a/compiler-core/src/type_/expression.rs b/compiler-core/src/type_/expression.rs index dffb93b0fb1..dd470894bc0 100644 --- a/compiler-core/src/type_/expression.rs +++ b/compiler-core/src/type_/expression.rs @@ -3828,24 +3828,21 @@ impl<'a, 'b> ExprTyper<'a, 'b> { self.infer_constant_bit_array(segments, location) } - // Handle constant records with spread separately - Constant::Record { + // Handle constant record updates + Constant::RecordUpdate { module, location, name, + record, arguments, - spread: Some(spread_const), .. } => { let constructor = self.infer_value_constructor(&module, &name, &location)?; - let (tag, field_map, _variant_index) = match &constructor.variant { + let (tag, field_map) = match &constructor.variant { ValueConstructorVariant::Record { - name, - field_map, - variant_index, - .. - } => (name.clone(), field_map.clone(), *variant_index), + name, field_map, .. + } => (name.clone(), field_map.clone()), ValueConstructorVariant::ModuleFn { .. } | ValueConstructorVariant::LocalVariable { .. } => { @@ -3858,19 +3855,19 @@ impl<'a, 'b> ExprTyper<'a, 'b> { } }; - // Type-check the spread constant - let typed_spread = self.infer_const(&None, *spread_const); + // Type-check the record being updated + let typed_record = self.infer_const(&None, *record); - // Unify types - the spread should have the same type as what the constructor returns + // Unify types - the record being updated should have the same type as what the constructor returns let expected_type = match constructor.type_.as_ref() { Type::Fn { return_: ret, .. } => ret.clone(), _ => constructor.type_.clone(), }; - unify(expected_type.clone(), typed_spread.type_()) - .map_err(|e| convert_unify_error(e, typed_spread.location()))?; + unify(expected_type.clone(), typed_record.type_()) + .map_err(|e| convert_unify_error(e, typed_record.location()))?; - // Resolve the spread if it's a constant variable - let resolved_spread = match &typed_spread { + // If the record being updated is a reference to a constant variable, resolve it to get the actual record value + let resolved_record = match &typed_record { Constant::Var { constructor: Some(value_constructor), .. @@ -3879,22 +3876,68 @@ impl<'a, 'b> ExprTyper<'a, 'b> { | ValueConstructorVariant::ModuleConstant { literal, .. } => { literal.clone() } - _ => typed_spread, + _ => typed_record, }, - _ => typed_spread, + _ => typed_record, }; - // Extract spread arguments - let spread_args = match resolved_spread { + // Get the field arguments from the record that we'll use as the base + let base_args = match resolved_record { Constant::Record { - arguments: spread_args, + arguments: base_args, .. - } => spread_args, + } => base_args, _ => { return Err(Error::RecordUpdateInvalidConstructor { location }); } }; + // Handle 0-arity constructors (constructors with no fields) + // For these, the type is not Fn, it's just the record type itself + if !matches!(constructor.type_.as_ref(), Type::Fn { .. }) { + // For 0-arity constructors, there are no fields to update + // If there are any override arguments, that's an error + if !arguments.is_empty() { + // The user is trying to update fields on a constructor with no fields + return Err(Error::RecordUpdateInvalidConstructor { location }); + } + + // Emit warning if no fields are being overridden (spreading with no updates) + self.problems + .warning(Warning::NoFieldsRecordUpdate { location }); + + // Just return the constructor itself + return Ok(Constant::Record { + module, + location, + name, + arguments: vec![], + type_: expected_type, + tag, + field_map, + record_constructor: Some(Box::new(constructor)), + }); + } + + // Extract the field types from the constructor function type + // Record constructors have type Fn(field1_type, field2_type, ...) -> RecordType + let field_types = match constructor.type_.as_ref() { + Type::Fn { + arguments: field_types, + .. + } => field_types, + _ => { + // This shouldn't happen for record constructors with fields + return Err(Error::RecordUpdateInvalidConstructor { location }); + } + }; + + // Emit warning if no fields are being overridden + if arguments.is_empty() { + self.problems + .warning(Warning::NoFieldsRecordUpdate { location }); + } + // Type-check explicit override arguments let mut typed_overrides = Vec::new(); for arg in arguments { @@ -3902,21 +3945,17 @@ impl<'a, 'b> ExprTyper<'a, 'b> { self.track_feature_usage(FeatureKind::LabelShorthandSyntax, arg.location); } - let label = arg.label.clone(); + let label = &arg.label; let typed_value = self.infer_const(&None, arg.value); - // Find expected type for this field and validate field exists - if let Some(label_name) = &label - && let Some(field_map) = &field_map - { - match field_map.fields.get(label_name) { + // Validate field exists and type check the override value + // field_map is None for tuple-like constructors (unlabelled fields) + // and Some for record constructors with named fields + if let Some(field_map) = &field_map { + match field_map.fields.get(label) { Some(&index) => { - if let Type::Fn { - arguments: field_types, - .. - } = constructor.type_.as_ref() - && let Some(expected_type) = field_types.get(index as usize) - { + // Type check: the override value must match the field type + if let Some(expected_type) = field_types.get(index as usize) { unify(expected_type.clone(), typed_value.type_()).map_err( |e| convert_unify_error(e, typed_value.location()), )?; @@ -3928,7 +3967,7 @@ impl<'a, 'b> ExprTyper<'a, 'b> { field_map.fields.keys().cloned().collect(), expected_type.clone(), arg.location, - label_name.clone(), + label.clone(), FieldAccessUsage::Other, )); } @@ -3936,15 +3975,15 @@ impl<'a, 'b> ExprTyper<'a, 'b> { } typed_overrides.push(CallArg { - label, + label: Some(label.clone()), value: typed_value, location: arg.location, - implicit: arg.implicit, + implicit: None, }); } - // Merge: start with spread args, override with explicit args - let mut final_arguments = spread_args; + // Merge: start with base record args, override with explicit update args + let mut final_arguments = base_args; for override_arg in typed_overrides { if let Some(label) = &override_arg.label && let Some(field_map) = &field_map @@ -3957,12 +3996,12 @@ impl<'a, 'b> ExprTyper<'a, 'b> { } } + // Return a fully expanded Record (not RecordUpdate) Ok(Constant::Record { module, location, name, arguments: final_arguments, - spread: None, type_: expected_type, tag, field_map, @@ -3975,10 +4014,9 @@ impl<'a, 'b> ExprTyper<'a, 'b> { location, name, arguments, - spread, // field_map, is always None here because untyped not yet unified .. - } if arguments.is_empty() && spread.is_none() => { + } if arguments.is_empty() => { // Type check the record constructor let constructor = self.infer_value_constructor(&module, &name, &location)?; @@ -4004,7 +4042,6 @@ impl<'a, 'b> ExprTyper<'a, 'b> { location, name, arguments: vec![], - spread: None, type_: constructor.type_.clone(), tag, field_map, @@ -4012,13 +4049,12 @@ impl<'a, 'b> ExprTyper<'a, 'b> { }) } - // Handle constant records without spread (normal case) + // Handle constant records (normal case) Constant::Record { module, location, name, mut arguments, - spread: None, // field_map, is always None here because untyped not yet unified .. } => { @@ -4150,7 +4186,6 @@ impl<'a, 'b> ExprTyper<'a, 'b> { location, name, arguments, - spread: None, type_: return_type, tag, field_map, diff --git a/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_variant_without_args_warning.snap b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_variant_without_args_warning.snap new file mode 100644 index 00000000000..d7a741b9929 --- /dev/null +++ b/compiler-core/src/type_/snapshots/gleam_core__type___tests__const_record_spread_variant_without_args_warning.snap @@ -0,0 +1,17 @@ +--- +source: compiler-core/src/type_/tests.rs +expression: "pub type Status { Active Inactive }\n pub const status1 = Active\n pub const status2 = Active(..status1)" +--- +----- SOURCE CODE +pub type Status { Active Inactive } + pub const status1 = Active + pub const status2 = Active(..status1) + +----- WARNING +warning: Fieldless record update + ┌─ /src/warning/wrn.gleam:3:29 + │ +3 │ pub const status2 = Active(..status1) + │ ^^^^^^^^^^^^^^^^^ This record update doesn't change any fields + +Hint: Add some fields to change or replace it with the record itself. diff --git a/compiler-core/src/type_/tests.rs b/compiler-core/src/type_/tests.rs index e15c475c6ca..8333fc7a2fb 100644 --- a/compiler-core/src/type_/tests.rs +++ b/compiler-core/src/type_/tests.rs @@ -2414,6 +2414,78 @@ fn const_record_spread_non_record() { ); } +#[test] +fn const_record_spread_fieldless_warning() { + // Test that spreading without any field overrides emits a warning + assert_module_infer!( + "pub type Animal { Animal(species: String) } + pub const alice = Animal(\"Cat\") + pub const dog = Animal(..alice)", + vec![ + ("Animal", "fn(String) -> Animal"), + ("alice", "Animal"), + ("dog", "Animal"), + ] + ); +} + +#[test] +fn const_record_spread_multi_variant() { + // Test spreading with multi-variant custom types + assert_module_infer!( + "pub type Pet { Dog(name: String, age: Int) Cat(name: String, breed: String) } + pub const my_dog = Dog(\"Rex\", 5) + pub const another_dog = Dog(..my_dog, name: \"Max\")", + vec![ + ("Cat", "fn(String, String) -> Pet"), + ("Dog", "fn(String, Int) -> Pet"), + ("another_dog", "Pet"), + ("my_dog", "Pet"), + ] + ); +} + +#[test] +fn const_record_spread_variant_without_args() { + // Test spreading with variants that have no arguments + assert_module_infer!( + "pub type Status { Active Inactive } + pub const status1 = Active + pub const status2 = Active(..status1)", + vec![ + ("Active", "Status"), + ("Inactive", "Status"), + ("status1", "Status"), + ("status2", "Status"), + ] + ); +} + +#[test] +fn const_record_spread_variant_without_args_warning() { + // Test that spreading 0-arity constructors produces a warning + assert_warning!( + "pub type Status { Active Inactive } + pub const status1 = Active + pub const status2 = Active(..status1)" + ); +} + +#[test] +fn const_record_spread_unlabelled_fields() { + // Test spreading with tuple-like constructors (unlabelled fields) + assert_module_infer!( + "pub type Point { Point(Int, Int) } + pub const origin = Point(0, 0) + pub const point = Point(..origin)", + vec![ + ("Point", "fn(Int, Int) -> Point"), + ("origin", "Point"), + ("point", "Point"), + ] + ); +} + #[test] fn module_constant_functions() { assert_module_infer!(