From 542255780de4662515e9a64a3781c738bc7ae47e Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Tue, 17 Feb 2026 16:20:32 -0500 Subject: [PATCH] feat: add tuple index access, multiline args, and effect unification fix - Tuple index: `pair.0`, `pair.1` syntax across parser, typechecker, interpreter, C/JS backends, formatter, linter, and symbol table - Multi-line function args: allow newlines inside argument lists - Fix effect unification for callback parameters (empty expected effects means "no constraint", not "must be pure") Co-Authored-By: Claude Opus 4.6 --- src/ast.rs | 7 +++++ src/codegen/c_backend.rs | 9 ++++-- src/codegen/js_backend.rs | 5 +++ src/formatter.rs | 3 ++ src/interpreter.rs | 28 +++++++++++++++++ src/linter.rs | 2 +- src/lsp.rs | 2 +- src/main.rs | 65 +++++++++++++++++++++++++++++++++++++++ src/parser.rs | 17 ++++++++++ src/symbol_table.rs | 2 +- src/typechecker.rs | 40 ++++++++++++++++++++++-- src/types.rs | 4 ++- 12 files changed, 176 insertions(+), 8 deletions(-) diff --git a/src/ast.rs b/src/ast.rs index d1e0146..a79e368 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -499,6 +499,12 @@ pub enum Expr { field: Ident, span: Span, }, + /// Tuple index access: tuple.0, tuple.1 + TupleIndex { + object: Box, + index: usize, + span: Span, + }, /// Lambda: fn(x, y) => x + y or fn(x: Int): Int => x + 1 Lambda { params: Vec, @@ -563,6 +569,7 @@ impl Expr { Expr::Call { span, .. } => *span, Expr::EffectOp { span, .. } => *span, Expr::Field { span, .. } => *span, + Expr::TupleIndex { span, .. } => *span, Expr::Lambda { span, .. } => *span, Expr::Let { span, .. } => *span, Expr::If { span, .. } => *span, diff --git a/src/codegen/c_backend.rs b/src/codegen/c_backend.rs index 2546182..6e6bb8a 100644 --- a/src/codegen/c_backend.rs +++ b/src/codegen/c_backend.rs @@ -3237,6 +3237,11 @@ impl CBackend { Ok(format!("{}.{}", obj, field.name)) } + Expr::TupleIndex { object, index, .. } => { + let obj = self.emit_expr(object)?; + Ok(format!("{}.__{}", obj, index)) + } + Expr::Match { scrutinee, arms, .. } => { self.emit_match(scrutinee, arms) } @@ -4518,7 +4523,7 @@ impl CBackend { || self.expr_uses_rc_vars_from_scope(right) } Expr::UnaryOp { operand, .. } => self.expr_uses_rc_vars_from_scope(operand), - Expr::Field { object, .. } => self.expr_uses_rc_vars_from_scope(object), + Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => self.expr_uses_rc_vars_from_scope(object), Expr::EffectOp { args, .. } => { args.iter().any(|a| self.expr_uses_rc_vars_from_scope(a)) } @@ -4707,7 +4712,7 @@ impl CBackend { self.collect_free_vars(val, bound, free); } } - Expr::Field { object, .. } => { + Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => { self.collect_free_vars(object, bound, free); } Expr::Match { scrutinee, arms, .. } => { diff --git a/src/codegen/js_backend.rs b/src/codegen/js_backend.rs index c271eb5..9aa23ef 100644 --- a/src/codegen/js_backend.rs +++ b/src/codegen/js_backend.rs @@ -1268,6 +1268,11 @@ impl JsBackend { Ok(format!("{}.{}", obj, field.name)) } + Expr::TupleIndex { object, index, .. } => { + let obj = self.emit_expr(object)?; + Ok(format!("{}[{}]", obj, index)) + } + Expr::Run { expr, handlers, .. } => { diff --git a/src/formatter.rs b/src/formatter.rs index 77e8f6d..fd855d2 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -598,6 +598,9 @@ impl Formatter { Expr::Field { object, field, .. } => { format!("{}.{}", self.format_expr(object), field.name) } + Expr::TupleIndex { object, index, .. } => { + format!("{}.{}", self.format_expr(object), index) + } Expr::If { condition, then_branch, else_branch, .. } => { format!( "if {} then {} else {}", diff --git a/src/interpreter.rs b/src/interpreter.rs index 9c7e79a..a0cf0ae 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -1415,6 +1415,34 @@ impl Interpreter { } } + Expr::TupleIndex { + object, + index, + span, + } => { + let obj_val = self.eval_expr(object, env)?; + match obj_val { + Value::Tuple(elements) => { + if *index < elements.len() { + Ok(EvalResult::Value(elements[*index].clone())) + } else { + Err(RuntimeError { + message: format!( + "Tuple index {} out of bounds for tuple with {} elements", + index, + elements.len() + ), + span: Some(*span), + }) + } + } + _ => Err(RuntimeError { + message: format!("Cannot use tuple index on {}", obj_val.type_name()), + span: Some(*span), + }), + } + } + Expr::Lambda { params, body, .. } => { let closure = Closure { params: params.iter().map(|p| p.name.name.clone()).collect(), diff --git a/src/linter.rs b/src/linter.rs index af7a9c8..e6191f3 100644 --- a/src/linter.rs +++ b/src/linter.rs @@ -510,7 +510,7 @@ impl Linter { self.collect_refs_expr(&arm.body); } } - Expr::Field { object, .. } => { + Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => { self.collect_refs_expr(object); } Expr::Record { fields, .. } => { diff --git a/src/lsp.rs b/src/lsp.rs index 5b76ada..e97bd67 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -1576,7 +1576,7 @@ fn collect_call_site_hints( collect_call_site_hints(source, e, param_names, hints); } } - Expr::Field { object, .. } => { + Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => { collect_call_site_hints(source, object, param_names, hints); } Expr::Run { expr, handlers, .. } => { diff --git a/src/main.rs b/src/main.rs index bd79116..43d215d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4831,6 +4831,71 @@ c")"#; } } + // ============ Multi-line Arguments Tests ============ + + #[test] + fn test_multiline_function_args() { + let source = r#" + fn add(a: Int, b: Int): Int = a + b + let result = add( + 1, + 2 + ) + "#; + assert_eq!(eval(source).unwrap(), "3"); + } + + #[test] + fn test_multiline_function_args_with_lambda() { + let source = r#" + let xs = List.map( + [1, 2, 3], + fn(x) => x * 2 + ) + "#; + assert_eq!(eval(source).unwrap(), "[2, 4, 6]"); + } + + // ============ Tuple Index Tests ============ + + #[test] + fn test_tuple_index_access() { + let source = r#" + let pair = (42, "hello") + let first = pair.0 + "#; + assert_eq!(eval(source).unwrap(), "42"); + } + + #[test] + fn test_tuple_index_access_second() { + let source = r#" + let pair = (42, "hello") + let second = pair.1 + "#; + assert_eq!(eval(source).unwrap(), "\"hello\""); + } + + #[test] + fn test_tuple_index_triple() { + let source = r#" + let triple = (1, 2, 3) + let sum = triple.0 + triple.1 + triple.2 + "#; + assert_eq!(eval(source).unwrap(), "6"); + } + + #[test] + fn test_tuple_index_in_function() { + let source = r#" + fn first(pair: (Int, String)): Int = pair.0 + fn second(pair: (Int, String)): String = pair.1 + let p = (42, "hello") + let result = first(p) + "#; + assert_eq!(eval(source).unwrap(), "42"); + } + // Exhaustiveness checking tests mod exhaustiveness_tests { use super::*; diff --git a/src/parser.rs b/src/parser.rs index 6c9f918..f723a63 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1646,6 +1646,20 @@ impl Parser { } else if self.check(TokenKind::Dot) { let start = expr.span(); self.advance(); + + // Check for tuple index access: expr.0, expr.1, etc. + if let TokenKind::Int(n) = self.peek_kind() { + let index = n as usize; + self.advance(); + let span = start.merge(self.previous_span()); + expr = Expr::TupleIndex { + object: Box::new(expr), + index, + span, + }; + continue; + } + let field = self.parse_ident()?; // Check if this is an effect operation: Effect.operation(args) @@ -1681,11 +1695,14 @@ impl Parser { fn parse_args(&mut self) -> Result, ParseError> { let mut args = Vec::new(); + self.skip_newlines(); while !self.check(TokenKind::RParen) { args.push(self.parse_expr()?); + self.skip_newlines(); if !self.check(TokenKind::RParen) { self.expect(TokenKind::Comma)?; + self.skip_newlines(); } } diff --git a/src/symbol_table.rs b/src/symbol_table.rs index bb76ad5..677a7a0 100644 --- a/src/symbol_table.rs +++ b/src/symbol_table.rs @@ -484,7 +484,7 @@ impl SymbolTable { self.visit_expr(arg, scope_idx); } } - Expr::Field { object, .. } => { + Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => { self.visit_expr(object, scope_idx); } Expr::If { condition, then_branch, else_branch, .. } => { diff --git a/src/typechecker.rs b/src/typechecker.rs index 1651309..cd3d236 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -335,7 +335,7 @@ fn references_params(expr: &Expr, params: &[&str]) -> bool { Statement::Expr(e) => references_params(e, params), }) || references_params(result, params) } - Expr::Field { object, .. } => references_params(object, params), + Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => references_params(object, params), Expr::Lambda { body, .. } => references_params(body, params), Expr::Tuple { elements, .. } => elements.iter().any(|e| references_params(e, params)), Expr::List { elements, .. } => elements.iter().any(|e| references_params(e, params)), @@ -519,7 +519,7 @@ fn has_recursive_calls(func_name: &str, body: &Expr) -> bool { Expr::Record { fields, .. } => { fields.iter().any(|(_, e)| has_recursive_calls(func_name, e)) } - Expr::Field { object, .. } => has_recursive_calls(func_name, object), + Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => has_recursive_calls(func_name, object), Expr::Let { value, body, .. } => { has_recursive_calls(func_name, value) || has_recursive_calls(func_name, body) } @@ -1673,6 +1673,42 @@ impl TypeChecker { span, } => self.infer_field(object, field, *span), + Expr::TupleIndex { + object, + index, + span, + } => { + let object_type = self.infer_expr(object); + match &object_type { + Type::Tuple(types) => { + if *index < types.len() { + types[*index].clone() + } else { + self.errors.push(TypeError { + message: format!( + "Tuple index {} out of bounds for tuple with {} elements", + index, + types.len() + ), + span: *span, + }); + Type::Error + } + } + Type::Var(_) => Type::var(), + _ => { + self.errors.push(TypeError { + message: format!( + "Cannot use tuple index on non-tuple type {}", + object_type + ), + span: *span, + }); + Type::Error + } + } + } + Expr::Lambda { params, return_type, diff --git a/src/types.rs b/src/types.rs index 52d6077..7c7af8b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -2040,7 +2040,9 @@ pub fn unify(t1: &Type, t2: &Type) -> Result { // Function's required effects (e1) must be a subset of available effects (e2) // A pure function (empty effects) can be called anywhere // A function requiring {Logger} can be called in context with {Logger} or {Logger, Console} - if !e1.is_subset(&e2) { + // When expected effects (e2) are empty, it means "no constraint" (e.g., callback parameter) + // so we allow any actual effects through + if !e2.is_empty() && !e1.is_subset(&e2) { return Err(format!( "Effect mismatch: expected {{{}}}, got {{{}}}", e1, e2