Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions crates/mq-check/src/constraint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Type> = 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
Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -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(),
Expand Down
33 changes: 33 additions & 0 deletions crates/mq-check/src/constraint/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,39 @@ pub(super) fn get_symbol_range(hir: &Hir, symbol_id: SymbolId) -> Option<mq_lang
hir.symbol(symbol_id).and_then(|symbol| symbol.source.text_range)
}

/// Returns the element type an array child contributes to its enclosing `[...]`.
///
/// For a plain element this is just its own type. For a `...expr` spread element
/// (`SymbolKind::Spread`), the spread target must itself be an array, so its
/// *element* type — not the array type itself — is what should be compared
/// against sibling elements (e.g. `[0, ...a, 99]` with `a: [number]` should be
/// seen as homogeneous `number` elements, not a 3-slot tuple).
pub(super) fn spread_element_type(
hir: &Hir,
child_id: SymbolId,
children_index: &ChildrenIndex,
ctx: &mut InferenceContext,
) -> 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
Expand Down
15 changes: 15 additions & 0 deletions crates/mq-check/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
18 changes: 18 additions & 0 deletions crates/mq-formatter/src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -502,6 +503,23 @@ impl Formatter {
}
}

fn format_spread(&mut self, node: &mq_lang::Shared<mq_lang::CstNode>, 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<mq_lang::CstNode>, indent_level: usize) {
if let mq_lang::CstNode {
kind: mq_lang::CstNodeKind::UnaryOp(op),
Expand Down
8 changes: 7 additions & 1 deletion crates/mq-hir/src/hir/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions crates/mq-hir/src/symbol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/mq-lang/src/ast/constants.rs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
87 changes: 64 additions & 23 deletions crates/mq-lang/src/ast/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, SyntaxError> {
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<Token>) -> Result<Shared<Node>, SyntaxError> {
let opening = (**lbrace_token).clone();
let token_id = self.token_arena.alloc(Shared::clone(lbrace_token));
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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<Token>) -> Result<Shared<Node>, 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<Token>) -> Result<Shared<Node>, SyntaxError> {
let opening = (**token).clone();
let token_id = self.token_arena.alloc(Shared::clone(token));
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions crates/mq-lang/src/cst/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ pub enum NodeKind {
SelectorCall,
Self_,
SelfAttr,
Spread,
Token,
Try,
Catch,
Expand Down
Loading