diff --git a/crates/mq-check/src/constraint.rs b/crates/mq-check/src/constraint.rs index 82e3bdcab..18df5d12a 100644 --- a/crates/mq-check/src/constraint.rs +++ b/crates/mq-check/src/constraint.rs @@ -14,7 +14,7 @@ use helpers::{ build_piped_call_args, collect_break_value_types, collect_pattern_variable_descendants, find_enclosing_function, find_lambda_function_child, get_post_loop_siblings, get_symbol_range, is_foreach_iterable_ref, is_inside_quote_block, merge_loop_types, might_receive_piped_input, resolve_builtin_call, resolve_pattern_type, - resolve_whole_type_pattern, + resolve_whole_type_pattern, spread_element_type, }; use pipe::{generate_block_constraints, generate_function_body_pipe_constraints, resolve_branch_body_type}; @@ -1188,10 +1188,12 @@ pub(super) fn generate_symbol_constraints( let array_ty = Type::array(Type::Var(elem_ty_var)); ctx.set_symbol_type(symbol_id, array_ty); } else { - // Get types of all elements + // Get types of all elements. A `...expr` spread element contributes the + // *element* type of its (array-typed) target, not the target's own type, + // so `[0, ...a, 99]` is still seen as homogeneous when `a: [number]`. let elem_tys: Vec = children .iter() - .map(|&child_id| ctx.get_or_create_symbol_type(child_id)) + .map(|&child_id| spread_element_type(hir, child_id, children_index, ctx)) .collect(); // Resolve element types to check for heterogeneous concrete types @@ -1271,6 +1273,14 @@ pub(super) fn generate_symbol_constraints( for &key_id in key_symbols { let symbol = hir.symbol(key_id); + + // `...expr` merges another dict's fields at runtime, which a fixed-field + // Record type can't represent; fall back to the permissive Dict type below. + if symbol.is_some_and(|s| s.kind == SymbolKind::Spread) { + all_string_keys = false; + break; + } + let key_name = symbol.and_then(|s| s.value.as_ref().map(|v| v.to_string())); let key_kind = symbol.map(|s| s.kind.clone()); @@ -1308,6 +1318,24 @@ pub(super) fn generate_symbol_constraints( let range = get_symbol_range(hir, symbol_id); for &key_id in key_symbols { + if hir.symbol(key_id).is_some_and(|s| s.kind == SymbolKind::Spread) { + // Checked against its own fresh key/value vars rather than this + // dict's shared `key_ty`: Record↔Dict unification forces String + // keys, which would misfire against Symbol-typed plain entries + // (e.g. `y: 99`) if it shared `key_ty` with them. + if let Some(&target_id) = get_children(children_index, key_id).first() { + let target_ty = ctx.get_or_create_symbol_type(target_id); + let (spread_k, spread_v) = (ctx.fresh_var(), ctx.fresh_var()); + ctx.add_constraint(Constraint::Equal( + target_ty, + Type::dict(Type::Var(spread_k), Type::Var(spread_v)), + range, + ConstraintOrigin::General, + )); + } + continue; + } + let k_ty = ctx.get_or_create_symbol_type(key_id); ctx.add_constraint(Constraint::Equal( key_ty.clone(), diff --git a/crates/mq-check/src/constraint/helpers.rs b/crates/mq-check/src/constraint/helpers.rs index ab4410f19..afbc01871 100644 --- a/crates/mq-check/src/constraint/helpers.rs +++ b/crates/mq-check/src/constraint/helpers.rs @@ -74,6 +74,39 @@ pub(super) fn get_symbol_range(hir: &Hir, symbol_id: SymbolId) -> Option Type { + if !hir.symbol(child_id).is_some_and(|s| s.kind == SymbolKind::Spread) { + return ctx.get_or_create_symbol_type(child_id); + } + + let target_ty = get_children(children_index, child_id) + .first() + .map(|&id| ctx.get_or_create_symbol_type(id)) + .unwrap_or_else(|| Type::Var(ctx.fresh_var())); + let elem_var = ctx.fresh_var(); + let range = get_symbol_range(hir, child_id); + ctx.add_constraint(Constraint::Equal( + target_ty, + Type::array(Type::Var(elem_var)), + range, + ConstraintOrigin::General, + )); + + Type::Var(elem_var) +} + /// Checks if a symbol belongs to a module source (include/import/module). /// /// Symbols from included/imported modules are trusted library code and should diff --git a/crates/mq-check/tests/integration_test.rs b/crates/mq-check/tests/integration_test.rs index 2c239b55e..e8374541b 100644 --- a/crates/mq-check/tests/integration_test.rs +++ b/crates/mq-check/tests/integration_test.rs @@ -87,6 +87,21 @@ fn test_dictionaries() { assert!(check_types(r#"{"a": 1, "b": 2}"#).is_empty()); } +#[rstest] +#[case::array_spread_basic("let a = [1, 2, 3];\n| let b = [0, ...a, 99];", true)] +#[case::array_spread_multiple("let a = [1];\n| let b = [2];\n| let c = [...a, ...b, 3];", true)] +#[case::array_spread_of_literal("[...[1, 2], 3]", true)] +#[case::array_spread_empty("let a = [];\n| [...a, 1];", true)] +#[case::array_spread_wrong_type("[...42];", false)] +#[case::dict_spread_basic("let base = {x: 1, y: 2};\n| let merged = {...base, y: 99, z: 3};", true)] +#[case::dict_spread_multiple("let a = {x: 1};\n| let b = {y: 2};\n| let c = {...a, ...b};", true)] +#[case::dict_spread_of_literal("{...{x: 1}, y: 2}", true)] +#[case::dict_spread_empty("let a = {};\n| {...a, x: 1};", true)] +#[case::dict_spread_wrong_type("{...42};", false)] +fn test_spread(#[case] code: &str, #[case] expect_no_errors: bool) { + assert_eq!(check_types(code).is_empty(), expect_no_errors, "code: {code}"); +} + #[test] fn test_conditionals() { assert!( diff --git a/crates/mq-formatter/src/formatter.rs b/crates/mq-formatter/src/formatter.rs index 7020f356c..183d3fb5c 100644 --- a/crates/mq-formatter/src/formatter.rs +++ b/crates/mq-formatter/src/formatter.rs @@ -302,6 +302,7 @@ impl Formatter { mq_lang::CstNodeKind::Pattern => self.format_pattern(&node, indent_level_consider_new_line), mq_lang::CstNodeKind::Token => self.append_token(&node, indent_level_consider_new_line), mq_lang::CstNodeKind::DictEntry => self.format_dict_entry(&node, indent_level_consider_new_line), + mq_lang::CstNodeKind::Spread => self.format_spread(&node, indent_level_consider_new_line), } } @@ -502,6 +503,23 @@ impl Formatter { } } + fn format_spread(&mut self, node: &mq_lang::Shared, indent_level: usize) { + if let mq_lang::CstNode { + kind: mq_lang::CstNodeKind::Spread, + token: Some(token), + .. + } = &**node + { + if node.has_new_line() { + self.append_indent(indent_level); + } + self.output.push_str(&token.to_string()); + self.format_node(mq_lang::Shared::clone(&node.children[0]), indent_level); + } else { + unreachable!("Expected Spread node"); + } + } + fn format_unary_op(&mut self, node: &mq_lang::Shared, indent_level: usize) { if let mq_lang::CstNode { kind: mq_lang::CstNodeKind::UnaryOp(op), diff --git a/crates/mq-hir/src/hir/lower.rs b/crates/mq-hir/src/hir/lower.rs index 42a28d396..f3f56e780 100644 --- a/crates/mq-hir/src/hir/lower.rs +++ b/crates/mq-hir/src/hir/lower.rs @@ -198,6 +198,9 @@ impl Hir { mq_lang::CstNodeKind::Dict => { self.add_dict_expr(node, source_id, scope_id, parent); } + mq_lang::CstNodeKind::Spread => { + self.add_spread_expr(node, source_id, scope_id, parent); + } mq_lang::CstNodeKind::Match => { self.add_match_expr(node, source_id, scope_id, parent); } @@ -250,6 +253,7 @@ impl Hir { simple_expr!(add_try_expr, mq_lang::CstNodeKind::Try, SymbolKind::Try); simple_expr!(add_catch_expr, mq_lang::CstNodeKind::Catch, SymbolKind::Catch); simple_expr!(add_array_expr, mq_lang::CstNodeKind::Array, SymbolKind::Array); + simple_expr!(add_spread_expr, mq_lang::CstNodeKind::Spread, SymbolKind::Spread); /// Lowers a `CstNodeKind::Assign` node into a `SymbolKind::Assign` symbol. /// @@ -1271,7 +1275,9 @@ impl Hir { }); for entry in node.children_without_token() { - if let (Some(key_node), Some(value_node)) = (entry.children.first(), entry.children.get(2)) { + if matches!(entry.kind, mq_lang::CstNodeKind::Spread) { + self.add_spread_expr(&entry, source_id, scope_id, Some(symbol_id)); + } else if let (Some(key_node), Some(value_node)) = (entry.children.first(), entry.children.get(2)) { let key_symbol_id = self.add_symbol(Symbol { value: key_node.name(), kind: match &key_node.token { diff --git a/crates/mq-hir/src/symbol.rs b/crates/mq-hir/src/symbol.rs index f8b9f8de7..9054e7d10 100644 --- a/crates/mq-hir/src/symbol.rs +++ b/crates/mq-hir/src/symbol.rs @@ -101,6 +101,8 @@ pub enum SymbolKind { }, Ref, Selector(mq_lang::Selector), + /// A `...expr` spread element inside an array or dict literal. + Spread, String, Symbol, UnaryOp, diff --git a/crates/mq-lang/src/ast/constants.rs b/crates/mq-lang/src/ast/constants.rs index 614944e90..28c4b9ebb 100644 --- a/crates/mq-lang/src/ast/constants.rs +++ b/crates/mq-lang/src/ast/constants.rs @@ -1,6 +1,8 @@ pub mod builtins { pub const ARRAY: &str = "array"; pub const DICT: &str = "dict"; + /// Marker ident for `...expr` spread elements; not a valid identifier, so it can't collide with user code. + pub const SPREAD: &str = "..."; pub const CONVERT: &str = "convert"; pub const GET: &str = "get"; diff --git a/crates/mq-lang/src/ast/parser.rs b/crates/mq-lang/src/ast/parser.rs index 98c287f16..46f1add80 100644 --- a/crates/mq-lang/src/ast/parser.rs +++ b/crates/mq-lang/src/ast/parser.rs @@ -646,6 +646,38 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { })) } + /// Consumes the comma or closing `}` after a dict entry. Returns `true` once closed. + fn parse_dict_separator(&mut self, opening: &Token) -> Result { + match self.tokens.peek() { + Some(token) if token.kind == TokenKind::Comma => { + self.tokens.next(); // Consume Comma + if let Some(next_token) = self.tokens.peek() + && next_token.kind == TokenKind::RBrace + { + self.tokens.next(); // Consume RBrace + return Ok(true); + } + Ok(false) + } + Some(token) if token.kind == TokenKind::RBrace => { + self.tokens.next(); // Consume RBrace + Ok(true) + } + Some(token) => Err(SyntaxError::ExpectedClosingBrace( + (***token).clone(), + Some(Box::new(opening.clone())), + )), + None => Err(SyntaxError::ExpectedClosingBrace( + Token { + range: opening.range, + kind: TokenKind::Eof, + module_id: self.module_id, + }, + Some(Box::new(opening.clone())), + )), + } + } + fn parse_dict(&mut self, lbrace_token: &Shared) -> Result, SyntaxError> { let opening = (**lbrace_token).clone(); let token_id = self.token_arena.alloc(Shared::clone(lbrace_token)); @@ -686,6 +718,14 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { None => return Err(eof_closing_err(&opening, self.module_id)), }; + if key_token.kind == TokenKind::DotDotDot { + pairs.push(self.parse_spread_element(key_token)?); + if self.parse_dict_separator(&opening)? { + break; + } + continue; + } + let key_node = match &key_token.kind { TokenKind::Ident(name) => Shared::new(Node { token_id: self.token_arena.alloc(Shared::clone(key_token)), @@ -722,29 +762,8 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { )), })); - // Peek for Comma or RBrace - match self.tokens.peek() { - Some(token) if token.kind == TokenKind::Comma => { - self.tokens.next(); // Consume Comma - // Check for trailing comma followed by RBrace - if let Some(next_token) = self.tokens.peek() - && next_token.kind == TokenKind::RBrace - { - self.tokens.next(); // Consume RBrace - break; - } - } - Some(token) if token.kind == TokenKind::RBrace => { - self.tokens.next(); // Consume RBrace - break; - } - Some(token) => { - return Err(SyntaxError::ExpectedClosingBrace( - (***token).clone(), - Some(Box::new(opening.clone())), - )); - } - None => return Err(eof_closing_err(&opening, self.module_id)), + if self.parse_dict_separator(&opening)? { + break; } } @@ -861,6 +880,25 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { })) } + /// Parses a `...expr` spread element, wrapping it in a `SPREAD` marker call that + /// `eval_builtin` expands in place when building the enclosing array/dict. + fn parse_spread_element(&mut self, dots_token: &Shared) -> Result, SyntaxError> { + let token_id = self.token_arena.alloc(Shared::clone(dots_token)); + let next_token = self + .tokens + .next() + .ok_or(SyntaxError::UnexpectedEOFDetected(self.module_id))?; + let inner = self.parse_expr(next_token)?; + + Ok(Shared::new(Node { + token_id, + expr: Shared::new(Expr::Call( + IdentWithToken::new_with_token(constants::builtins::SPREAD, Some(Shared::clone(dots_token))), + smallvec![inner], + )), + })) + } + fn parse_array(&mut self, token: &Shared) -> Result, SyntaxError> { let opening = (**token).clone(); let token_id = self.token_arena.alloc(Shared::clone(token)); @@ -880,6 +918,9 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { )); } TokenKind::Comma => continue, + TokenKind::DotDotDot => { + elements.push(self.parse_spread_element(elem_token)?); + } _ => { let expr = self.parse_expr(elem_token)?; elements.push(expr); diff --git a/crates/mq-lang/src/cst/node.rs b/crates/mq-lang/src/cst/node.rs index 0326d16ea..2f8ea85ae 100644 --- a/crates/mq-lang/src/cst/node.rs +++ b/crates/mq-lang/src/cst/node.rs @@ -119,6 +119,7 @@ pub enum NodeKind { SelectorCall, Self_, SelfAttr, + Spread, Token, Try, Catch, diff --git a/crates/mq-lang/src/cst/parser.rs b/crates/mq-lang/src/cst/parser.rs index fd82efb67..141aae399 100644 --- a/crates/mq-lang/src/cst/parser.rs +++ b/crates/mq-lang/src/cst/parser.rs @@ -1347,6 +1347,26 @@ impl<'a> Parser<'a> { Ok(Shared::new(node)) } + /// Parses a `...expr` spread element inside an array or dict literal, mirroring + /// `parse_unary_op`'s shape: the `...` token lives on the node itself, and the + /// spread target is the sole child. + fn parse_spread(&mut self, leading_trivia: Vec) -> Result, ParseError> { + let dots_token = self.advance().unwrap(); + let trailing_trivia = self.parse_trailing_trivia(); + let mut node = Node { + kind: NodeKind::Spread, + token: Some(Shared::clone(dots_token)), + leading_trivia, + trailing_trivia, + children: Vec::new(), + }; + + let operand_leading_trivia = self.parse_leading_trivia(); + node.children = vec![self.parse_expr(operand_leading_trivia, false, false)?]; + + Ok(Shared::new(node)) + } + fn parse_array(&mut self, leading_trivia: Vec) -> Result, ParseError> { let mut children: Vec> = Vec::with_capacity(12); @@ -1356,7 +1376,11 @@ impl<'a> Parser<'a> { |kind| matches!(kind, TokenKind::RBracket), |parser| { let leading_trivia = parser.parse_leading_trivia(); - parser.parse_expr(leading_trivia, false, false) + if matches!(parser.peek_token()?.kind, TokenKind::DotDotDot) { + parser.parse_spread(leading_trivia) + } else { + parser.parse_expr(leading_trivia, false, false) + } }, )?; children.append(&mut list); @@ -1415,31 +1439,37 @@ impl<'a> Parser<'a> { loop { let leading_trivia = self.parse_leading_trivia(); - let mut dict_entry = Node { - kind: NodeKind::DictEntry, - token: None, - leading_trivia, - trailing_trivia: Vec::new(), - children: Vec::new(), - }; - let mut entry: Vec> = Vec::with_capacity(3); - let key_node = { - let leading_trivia = self.parse_leading_trivia(); - self.parse_dict_key(leading_trivia) - }?; - let colon_node = self.next_node(|token_kind| matches!(token_kind, TokenKind::Colon), NodeKind::Token)?; + if matches!(self.peek_token()?.kind, TokenKind::DotDotDot) { + children.push(self.parse_spread(leading_trivia)?); + } else { + let mut dict_entry = Node { + kind: NodeKind::DictEntry, + token: None, + leading_trivia, + trailing_trivia: Vec::new(), + children: Vec::new(), + }; + let mut entry: Vec> = Vec::with_capacity(3); + let key_node = { + let leading_trivia = self.parse_leading_trivia(); + self.parse_dict_key(leading_trivia) + }?; - let value_node = { - let leading_trivia = self.parse_leading_trivia(); - self.parse_expr(leading_trivia, false, false) - }?; + let colon_node = + self.next_node(|token_kind| matches!(token_kind, TokenKind::Colon), NodeKind::Token)?; - entry.push(key_node); - entry.push(colon_node); - entry.push(value_node); - dict_entry.children = entry; - children.push(Shared::new(dict_entry)); + let value_node = { + let leading_trivia = self.parse_leading_trivia(); + self.parse_expr(leading_trivia, false, false) + }?; + + entry.push(key_node); + entry.push(colon_node); + entry.push(value_node); + dict_entry.children = entry; + children.push(Shared::new(dict_entry)); + } let leading_trivia = self.parse_leading_trivia(); let token = Shared::clone(self.peek_token()?); @@ -4705,6 +4735,56 @@ mod tests { ErrorReporter::default() ) )] + #[case::array_with_spread( + vec![ + Shared::new(token(TokenKind::LBracket)), + Shared::new(token(TokenKind::DotDotDot)), + Shared::new(token(TokenKind::Ident("a".into()))), + Shared::new(token(TokenKind::RBracket)), + ], + ( + vec![ + Shared::new(Node { + kind: NodeKind::Array, + token: None, + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: vec![ + Shared::new(Node { + kind: NodeKind::Token, + token: Some(Shared::new(token(TokenKind::LBracket))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: Vec::new(), + }), + Shared::new(Node { + kind: NodeKind::Spread, + token: Some(Shared::new(token(TokenKind::DotDotDot))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: vec![ + Shared::new(Node { + kind: NodeKind::Ident, + token: Some(Shared::new(token(TokenKind::Ident("a".into())))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: Vec::new(), + }), + ], + }), + Shared::new(Node { + kind: NodeKind::Token, + token: Some(Shared::new(token(TokenKind::RBracket))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: Vec::new(), + }), + ], + }), + ], + ErrorReporter::default() + ) + )] #[case::dict_empty( vec![ Shared::new(token(TokenKind::LBrace)), @@ -4908,6 +4988,56 @@ mod tests { ErrorReporter::default() ) )] + #[case::dict_with_spread( + vec![ + Shared::new(token(TokenKind::LBrace)), + Shared::new(token(TokenKind::DotDotDot)), + Shared::new(token(TokenKind::Ident("a".into()))), + Shared::new(token(TokenKind::RBrace)), + ], + ( + vec![ + Shared::new(Node { + kind: NodeKind::Dict, + token: None, + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: vec![ + Shared::new(Node { + kind: NodeKind::Token, + token: Some(Shared::new(token(TokenKind::LBrace))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: Vec::new(), + }), + Shared::new(Node { + kind: NodeKind::Spread, + token: Some(Shared::new(token(TokenKind::DotDotDot))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: vec![ + Shared::new(Node { + kind: NodeKind::Ident, + token: Some(Shared::new(token(TokenKind::Ident("a".into())))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: Vec::new(), + }), + ], + }), + Shared::new(Node { + kind: NodeKind::Token, + token: Some(Shared::new(token(TokenKind::RBrace))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: Vec::new(), + }), + ], + }), + ], + ErrorReporter::default() + ) + )] #[case::eq_eq( vec![ Shared::new(token(TokenKind::Ident("a".into()))), diff --git a/crates/mq-lang/src/eval.rs b/crates/mq-lang/src/eval.rs index b1c659a32..8c7802739 100644 --- a/crates/mq-lang/src/eval.rs +++ b/crates/mq-lang/src/eval.rs @@ -5,7 +5,6 @@ use std::sync::LazyLock; #[cfg(feature = "debugger")] use crate::DebuggerHandler; use crate::Module; -#[cfg(feature = "debugger")] use crate::ast::constants; #[cfg(feature = "debugger")] use crate::eval::debugger::DefaultDebuggerHandler; @@ -45,6 +44,9 @@ use runtime_value::RuntimeValue; static TYPE_IDENT: LazyLock = LazyLock::new(|| Ident::new("type")); static DYNAMIC_IDENT: LazyLock = LazyLock::new(|| Ident::new("")); +static SPREAD_IDENT: LazyLock = LazyLock::new(|| Ident::new(constants::builtins::SPREAD)); +static ARRAY_IDENT: LazyLock = LazyLock::new(|| Ident::new(constants::builtins::ARRAY)); +static DICT_IDENT: LazyLock = LazyLock::new(|| Ident::new(constants::builtins::DICT)); /// Control flow signals for internal evaluation. /// @@ -1710,12 +1712,69 @@ impl Evaluator { args: &ast::Args, env: &Shared>, ) -> EvalResult { - let args: Result = - args.iter().map(|arg| self.eval_expr(runtime_value, arg, env)).collect(); - builtin::eval_builtin(runtime_value, ident, args?, env) + let args = self.eval_call_args(runtime_value, &node, ident, args, env)?; + builtin::eval_builtin(runtime_value, ident, args, env) .map_err(|e| EvalError::from(e.to_runtime_error((*node).clone(), Shared::clone(&self.token_arena)))) } + /// Evaluates call args, expanding `...expr` spread markers for `array`/`dict` calls. + /// Other builtins take the plain evaluation fast path below. + fn eval_call_args( + &mut self, + runtime_value: &RuntimeValue, + node: &Shared, + ident: &Ident, + args: &ast::Args, + env: &Shared>, + ) -> Result { + if *ident != *ARRAY_IDENT && *ident != *DICT_IDENT { + return args.iter().map(|arg| self.eval_expr(runtime_value, arg, env)).collect(); + } + + let mut evaluated: builtin::Args = Vec::with_capacity(args.len()); + + for arg in args.iter() { + match &*arg.expr { + ast::Expr::Call(spread_ident, spread_args) if spread_ident.name == *SPREAD_IDENT => { + let spread_value = self.eval_expr(runtime_value, &spread_args[0], env)?; + self.expand_spread(node, ident, spread_value, &mut evaluated)?; + } + _ => evaluated.push(self.eval_expr(runtime_value, arg, env)?), + } + } + + Ok(evaluated) + } + + /// Splices a spread target into `out`: array elements, or `[Symbol(key), value]` + /// pairs for a dict. `None` contributes nothing; any other type is a runtime error. + fn expand_spread( + &self, + node: &Shared, + ident: &Ident, + value: RuntimeValue, + out: &mut builtin::Args, + ) -> Result<(), EvalError> { + match value { + RuntimeValue::None => Ok(()), + RuntimeValue::Dict(map) if *ident == *DICT_IDENT => { + out.extend( + map.into_iter() + .map(|(k, v)| RuntimeValue::Array(vec![RuntimeValue::Symbol(k), v])), + ); + Ok(()) + } + RuntimeValue::Array(items) if *ident != *DICT_IDENT => { + out.extend(items); + Ok(()) + } + other => Err(EvalError::from( + builtin::Error::InvalidTypes(ident.to_string(), vec![other]) + .to_runtime_error((**node).clone(), Shared::clone(&self.token_arena)), + )), + } + } + #[inline(always)] fn eval_call_dynamic( &mut self, diff --git a/crates/mq-lang/src/lexer.rs b/crates/mq-lang/src/lexer.rs index b306ceb07..24737a99e 100644 --- a/crates/mq-lang/src/lexer.rs +++ b/crates/mq-lang/src/lexer.rs @@ -215,6 +215,7 @@ define_token_parser!(ne_eq, "!=", TokenKind::NeEq); define_token_parser!(plus, "+", TokenKind::Plus); define_token_parser!(pipe, "|", TokenKind::Pipe); define_token_parser!(percent, "%", TokenKind::Percent); +define_token_parser!(spread_op, "...", TokenKind::DotDotDot); define_token_parser!(range_op, "..", TokenKind::DoubleDot); define_token_parser!(r_bracket, "]", TokenKind::RBracket); define_token_parser!(r_paren, ")", TokenKind::RParen); @@ -301,6 +302,7 @@ fn binary_op(input: Span) -> IResult { asterisk, slash, percent, + spread_op, range_op, )) .parse(input) diff --git a/crates/mq-lang/src/lexer/token.rs b/crates/mq-lang/src/lexer/token.rs index ed5921d3b..7f7a4a1d7 100644 --- a/crates/mq-lang/src/lexer/token.rs +++ b/crates/mq-lang/src/lexer/token.rs @@ -58,6 +58,7 @@ pub enum TokenKind { Colon, DoubleColon, DoubleSlashEqual, + DotDotDot, Comma, Comment(String), Continue, @@ -187,6 +188,7 @@ impl Display for TokenKind { TokenKind::Do => write!(f, "do"), TokenKind::DoubleColon => write!(f, "::"), TokenKind::DoubleSlashEqual => write!(f, "//="), + TokenKind::DotDotDot => write!(f, "..."), TokenKind::Elif => write!(f, "elif"), TokenKind::Else => write!(f, "else"), TokenKind::End => write!(f, "end"), diff --git a/crates/mq-lang/tests/integration_tests.rs b/crates/mq-lang/tests/integration_tests.rs index d3c1cfac8..7a7724161 100644 --- a/crates/mq-lang/tests/integration_tests.rs +++ b/crates/mq-lang/tests/integration_tests.rs @@ -881,6 +881,30 @@ fn engine() -> DefaultEngine { #[case::array_length("len([1, 2, 3, 4])", vec![RuntimeValue::Number(0.into())], Ok(vec![RuntimeValue::Number(4.into())].into()))] +#[case::array_spread_basic("let a = [1, 2, 3] | let b = [4, 5, 6] | [...a, ...b]", + vec![RuntimeValue::Number(0.into())], + Ok(vec![RuntimeValue::Array(vec![ + RuntimeValue::Number(1.into()), RuntimeValue::Number(2.into()), RuntimeValue::Number(3.into()), + RuntimeValue::Number(4.into()), RuntimeValue::Number(5.into()), RuntimeValue::Number(6.into()), + ])].into()))] +#[case::array_spread_with_surrounding_elements("let a = [1, 2, 3] | [0, ...a, 99]", + vec![RuntimeValue::Number(0.into())], + Ok(vec![RuntimeValue::Array(vec![ + RuntimeValue::Number(0.into()), RuntimeValue::Number(1.into()), RuntimeValue::Number(2.into()), + RuntimeValue::Number(3.into()), RuntimeValue::Number(99.into()), + ])].into()))] +#[case::array_spread_empty_array("let a = [] | [...a, 1]", + vec![RuntimeValue::Number(0.into())], + Ok(vec![RuntimeValue::Array(vec![RuntimeValue::Number(1.into())])].into()))] +#[case::array_spread_none_contributes_nothing("[...None, 1, 2]", + vec![RuntimeValue::Number(0.into())], + Ok(vec![RuntimeValue::Array(vec![RuntimeValue::Number(1.into()), RuntimeValue::Number(2.into())])].into()))] +#[case::array_spread_nested("let a = [1, 2] | [...a, ...[3, 4]]", + vec![RuntimeValue::Number(0.into())], + Ok(vec![RuntimeValue::Array(vec![ + RuntimeValue::Number(1.into()), RuntimeValue::Number(2.into()), + RuntimeValue::Number(3.into()), RuntimeValue::Number(4.into()), + ])].into()))] #[case::dict_new_empty("dict()", vec![RuntimeValue::Number(0.into())], Ok(vec![RuntimeValue::new_dict()].into()))] @@ -949,6 +973,39 @@ fn engine() -> DefaultEngine { dict.insert(Ident::new("b"), RuntimeValue::Number(2.into())); dict.into() }].into()))] +#[case::dict_spread_basic("let base = {x: 1, y: 2} | {...base, z: 3}", + vec![RuntimeValue::Number(0.into())], + Ok(vec![{ + let mut dict = BTreeMap::new(); + dict.insert(Ident::new("x"), RuntimeValue::Number(1.into())); + dict.insert(Ident::new("y"), RuntimeValue::Number(2.into())); + dict.insert(Ident::new("z"), RuntimeValue::Number(3.into())); + dict.into() + }].into()))] +#[case::dict_spread_later_key_overrides("let base = {x: 1, y: 2} | {...base, y: 99, z: 3}", + vec![RuntimeValue::Number(0.into())], + Ok(vec![{ + let mut dict = BTreeMap::new(); + dict.insert(Ident::new("x"), RuntimeValue::Number(1.into())); + dict.insert(Ident::new("y"), RuntimeValue::Number(99.into())); + dict.insert(Ident::new("z"), RuntimeValue::Number(3.into())); + dict.into() + }].into()))] +#[case::dict_spread_multiple("let a = {x: 1} | let b = {y: 2} | {...a, ...b}", + vec![RuntimeValue::Number(0.into())], + Ok(vec![{ + let mut dict = BTreeMap::new(); + dict.insert(Ident::new("x"), RuntimeValue::Number(1.into())); + dict.insert(Ident::new("y"), RuntimeValue::Number(2.into())); + dict.into() + }].into()))] +#[case::dict_spread_none_contributes_nothing("{...None, x: 1}", + vec![RuntimeValue::Number(0.into())], + Ok(vec![{ + let mut dict = BTreeMap::new(); + dict.insert(Ident::new("x"), RuntimeValue::Number(1.into())); + dict.into() + }].into()))] #[case::dict_map_transform_values(" def double_value(kv): array(first(kv), mul(last(kv), 2)); @@ -3647,6 +3704,10 @@ fn test_eval(mut engine: Engine, #[case] program: &str, #[case] input: Vec) { assert!(engine.eval(program, input.into_iter()).is_err()); } diff --git a/crates/mq-lint/src/rules/correctness/unused_variable.rs b/crates/mq-lint/src/rules/correctness/unused_variable.rs index a7a5b7fc0..4d06223d5 100644 --- a/crates/mq-lint/src/rules/correctness/unused_variable.rs +++ b/crates/mq-lint/src/rules/correctness/unused_variable.rs @@ -91,6 +91,8 @@ mod tests { #[case("let _ignored = .h1")] #[case(r#"s"${x}""#)] #[case(r#"let x = 1 | s"${x}""#)] + #[case("let a = [1, 2, 3] | [...a]")] + #[case("let base = {x: 1} | {...base, y: 2}")] fn no_diagnostic(#[case] code: &str) { let diags = check(code); assert_eq!(diags.len(), 0); diff --git a/crates/mq-lsp/src/semantic_tokens.rs b/crates/mq-lsp/src/semantic_tokens.rs index f1a8f1a58..c991c5424 100644 --- a/crates/mq-lsp/src/semantic_tokens.rs +++ b/crates/mq-lsp/src/semantic_tokens.rs @@ -52,9 +52,10 @@ pub(crate) fn response(hir: Arc>, url: Url) -> Vec token_type(ls_types::SemanticTokenType::PARAMETER), - mq_hir::SymbolKind::Assign | mq_hir::SymbolKind::BinaryOp | mq_hir::SymbolKind::UnaryOp => { - token_type(ls_types::SemanticTokenType::OPERATOR) - } + mq_hir::SymbolKind::Assign + | mq_hir::SymbolKind::BinaryOp + | mq_hir::SymbolKind::UnaryOp + | mq_hir::SymbolKind::Spread => token_type(ls_types::SemanticTokenType::OPERATOR), mq_hir::SymbolKind::Dict | mq_hir::SymbolKind::Boolean | mq_hir::SymbolKind::Array => { token_type(ls_types::SemanticTokenType::TYPE) } diff --git a/docs/books/src/reference/types_and_values.md b/docs/books/src/reference/types_and_values.md index e88e67770..e005e969f 100644 --- a/docs/books/src/reference/types_and_values.md +++ b/docs/books/src/reference/types_and_values.md @@ -134,6 +134,28 @@ get(d, "name") # Same as di["name"] d | get("age") # Same as d["age"] ``` +### Spread Operator + +The `...` spread operator expands an array or dict inline inside an array or +dict literal. + +```mq +let a = [1, 2, 3] +| let b = [4, 5, 6] +| let c = [...a, ...b] # [1, 2, 3, 4, 5, 6] +| let d = [0, ...a, 99] # [0, 1, 2, 3, 99] +``` + +```mq +let base = {x: 1, y: 2} +| let merged = {...base, y: 99, z: 3} # {x: 1, y: 99, z: 3} +``` + +When the same key appears more than once, later keys override earlier ones, +including keys coming from a spread. Spreading `None` contributes nothing; +spreading any other non-array (in `[...]`) or non-dict (in `{...}`) value is a +type error. + ### Dynamic Access Both arrays and dictionaries support dynamic access using variables: