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.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 29dd259361c..d4f0c1d2d3a 100644 --- a/compiler-core/src/ast/constant.rs +++ b/compiler-core/src/ast/constant.rs @@ -46,6 +46,17 @@ pub enum Constant { 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>, @@ -85,6 +96,7 @@ impl TypedConstant { } Constant::List { type_, .. } | Constant::Record { type_, .. } + | Constant::RecordUpdate { type_, .. } | Constant::Var { type_, .. } | Constant::Invalid { type_, .. } => type_.clone(), } @@ -108,6 +120,16 @@ impl TypedConstant { .iter() .find_map(|argument| argument.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() .find_map(|segment| segment.find_node(byte_index)) @@ -139,6 +161,7 @@ impl TypedConstant { } => value_constructor .as_ref() .map(|constructor| constructor.definition_location()), + Constant::RecordUpdate { .. } => None, } } @@ -161,6 +184,17 @@ impl TypedConstant { .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(|arg| arg.value.referenced_variables()) + .fold(im::hashset![], im::HashSet::union); + record_vars.union(arg_vars) + } + Constant::BitArray { segments, .. } => segments .iter() .map(|segment| { @@ -194,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, .. } @@ -212,6 +247,7 @@ impl Constant { Constant::Tuple { .. } | Constant::List { .. } | Constant::Record { .. } + | Constant::RecordUpdate { .. } | Constant::BitArray { .. } | Constant::StringConcatenation { .. } | Constant::Invalid { .. } => false, @@ -234,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 3a2ee41d0f0..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, @@ -978,6 +978,17 @@ pub trait UntypedConstantFolder { record_constructor: _, } => 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) } @@ -1072,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, @@ -1170,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 c7d2e73bda7..9a4342dab32 100644 --- a/compiler-core/src/erlang.rs +++ b/compiler-core/src/erlang.rs @@ -1542,11 +1542,19 @@ 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::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 { @@ -3305,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 33cbb490620..f08749b1b06 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)) @@ -1855,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 { @@ -2253,6 +2260,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_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/parse.rs b/compiler-core/src/parse.rs index 016b62b8a24..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, }; @@ -3310,28 +3310,77 @@ 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))?; - let (_, par_e) = self.expect_one_following_series( - &Token::RightParen, - "a constant record argument", - )?; - if arguments.is_empty() { - return parse_error( - ParseErrorType::ConstantRecordConstructorNoArguments, - SrcSpan::new(par_s, par_e), - ); + // Check for spread syntax: Record(..base, ...) + 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), + )?; + } + + 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", + )?; + + 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, + 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 }, @@ -3408,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 new file mode 100644 index 00000000000..2e17684b3f0 --- /dev/null +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_all_fields.snap @@ -0,0 +1,348 @@ +--- +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, + }, + ], + 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: 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: [ + ConstantRecordUpdateArg { + label: "name", + location: SrcSpan { + start: 139, + end: 150, + }, + value: String { + location: SrcSpan { + start: 145, + end: 150, + }, + value: "Bob", + }, + }, + ConstantRecordUpdateArg { + label: "age", + location: SrcSpan { + start: 152, + end: 159, + }, + value: Int { + location: SrcSpan { + start: 157, + end: 159, + }, + value: "25", + int_value: 25, + }, + }, + ], + tag: (), + type_: (), + field_map: 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..a34cd9ae27e --- /dev/null +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_basic.snap @@ -0,0 +1,285 @@ +--- +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, + }, + ], + 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: 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: [ + ConstantRecordUpdateArg { + label: "name", + location: SrcSpan { + start: 113, + end: 124, + }, + value: String { + location: SrcSpan { + start: 119, + end: 124, + }, + value: "Bob", + }, + }, + ], + tag: (), + type_: (), + field_map: 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..625bb84dfbe --- /dev/null +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_only.snap @@ -0,0 +1,270 @@ +--- +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, + }, + ], + 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: RecordUpdate { + location: SrcSpan { + start: 97, + end: 112, + }, + module: None, + name: "Person", + record: Var { + location: SrcSpan { + start: 106, + end: 111, + }, + module: None, + name: "alice", + constructor: None, + type_: (), + }, + arguments: [], + tag: (), + type_: (), + field_map: 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..6f9ec7b2320 --- /dev/null +++ b/compiler-core/src/parse/snapshots/gleam_core__parse__tests__const_record_spread_with_module.snap @@ -0,0 +1,116 @@ +--- +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: RecordUpdate { + location: SrcSpan { + start: 21, + end: 61, + }, + module: Some( + ( + "other", + SrcSpan { + start: 21, + end: 26, + }, + ), + ), + name: "Record", + 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, + }, + value: Var { + location: SrcSpan { + start: 55, + end: 60, + }, + module: None, + name: "value", + constructor: None, + type_: (), + }, + }, + ], + tag: (), + type_: (), + field_map: 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..dd470894bc0 100644 --- a/compiler-core/src/type_/expression.rs +++ b/compiler-core/src/type_/expression.rs @@ -3828,6 +3828,187 @@ impl<'a, 'b> ExprTyper<'a, 'b> { self.infer_constant_bit_array(segments, location) } + // Handle constant record updates + Constant::RecordUpdate { + module, + location, + name, + record, + arguments, + .. + } => { + let constructor = self.infer_value_constructor(&module, &name, &location)?; + + let (tag, field_map) = match &constructor.variant { + ValueConstructorVariant::Record { + name, field_map, .. + } => (name.clone(), field_map.clone()), + + ValueConstructorVariant::ModuleFn { .. } + | ValueConstructorVariant::LocalVariable { .. } => { + return Err(Error::NonLocalClauseGuardVariable { location, name }); + } + + ValueConstructorVariant::ModuleConstant { literal, .. } + | ValueConstructorVariant::LocalConstant { literal } => { + return Ok(literal.clone()); + } + }; + + // Type-check the record being updated + let typed_record = self.infer_const(&None, *record); + + // 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_record.type_()) + .map_err(|e| convert_unify_error(e, typed_record.location()))?; + + // 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), + .. + } => match &value_constructor.variant { + ValueConstructorVariant::LocalConstant { literal } + | ValueConstructorVariant::ModuleConstant { literal, .. } => { + literal.clone() + } + _ => typed_record, + }, + _ => typed_record, + }; + + // Get the field arguments from the record that we'll use as the base + let base_args = match resolved_record { + Constant::Record { + arguments: base_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 { + if arg.uses_label_shorthand() { + self.track_feature_usage(FeatureKind::LabelShorthandSyntax, arg.location); + } + + let label = &arg.label; + let typed_value = self.infer_const(&None, arg.value); + + // 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) => { + // 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()), + )?; + } + } + 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.clone(), + FieldAccessUsage::Other, + )); + } + } + } + + typed_overrides.push(CallArg { + label: Some(label.clone()), + value: typed_value, + location: arg.location, + implicit: None, + }); + } + + // 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 + && 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; + } + } + + // Return a fully expanded Record (not RecordUpdate) + Ok(Constant::Record { + module, + location, + name, + arguments: final_arguments, + type_: expected_type, + tag, + field_map, + record_constructor: Some(Box::new(constructor)), + }) + } + Constant::Record { module, location, @@ -3868,6 +4049,7 @@ impl<'a, 'b> ExprTyper<'a, 'b> { }) } + // Handle constant records (normal case) Constant::Record { module, location, 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_/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 bfcc5d0b7f9..8333fc7a2fb 100644 --- a/compiler-core/src/type_/tests.rs +++ b/compiler-core/src/type_/tests.rs @@ -2305,6 +2305,187 @@ 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 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!(