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 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 16:20:32 -05:00
parent bac63bab2a
commit 542255780d
12 changed files with 176 additions and 8 deletions

View File

@@ -499,6 +499,12 @@ pub enum Expr {
field: Ident, field: Ident,
span: Span, span: Span,
}, },
/// Tuple index access: tuple.0, tuple.1
TupleIndex {
object: Box<Expr>,
index: usize,
span: Span,
},
/// Lambda: fn(x, y) => x + y or fn(x: Int): Int => x + 1 /// Lambda: fn(x, y) => x + y or fn(x: Int): Int => x + 1
Lambda { Lambda {
params: Vec<Parameter>, params: Vec<Parameter>,
@@ -563,6 +569,7 @@ impl Expr {
Expr::Call { span, .. } => *span, Expr::Call { span, .. } => *span,
Expr::EffectOp { span, .. } => *span, Expr::EffectOp { span, .. } => *span,
Expr::Field { span, .. } => *span, Expr::Field { span, .. } => *span,
Expr::TupleIndex { span, .. } => *span,
Expr::Lambda { span, .. } => *span, Expr::Lambda { span, .. } => *span,
Expr::Let { span, .. } => *span, Expr::Let { span, .. } => *span,
Expr::If { span, .. } => *span, Expr::If { span, .. } => *span,

View File

@@ -3237,6 +3237,11 @@ impl CBackend {
Ok(format!("{}.{}", obj, field.name)) Ok(format!("{}.{}", obj, field.name))
} }
Expr::TupleIndex { object, index, .. } => {
let obj = self.emit_expr(object)?;
Ok(format!("{}.__{}", obj, index))
}
Expr::Match { scrutinee, arms, .. } => { Expr::Match { scrutinee, arms, .. } => {
self.emit_match(scrutinee, arms) self.emit_match(scrutinee, arms)
} }
@@ -4518,7 +4523,7 @@ impl CBackend {
|| self.expr_uses_rc_vars_from_scope(right) || self.expr_uses_rc_vars_from_scope(right)
} }
Expr::UnaryOp { operand, .. } => self.expr_uses_rc_vars_from_scope(operand), 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, .. } => { Expr::EffectOp { args, .. } => {
args.iter().any(|a| self.expr_uses_rc_vars_from_scope(a)) 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); self.collect_free_vars(val, bound, free);
} }
} }
Expr::Field { object, .. } => { Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => {
self.collect_free_vars(object, bound, free); self.collect_free_vars(object, bound, free);
} }
Expr::Match { scrutinee, arms, .. } => { Expr::Match { scrutinee, arms, .. } => {

View File

@@ -1268,6 +1268,11 @@ impl JsBackend {
Ok(format!("{}.{}", obj, field.name)) Ok(format!("{}.{}", obj, field.name))
} }
Expr::TupleIndex { object, index, .. } => {
let obj = self.emit_expr(object)?;
Ok(format!("{}[{}]", obj, index))
}
Expr::Run { Expr::Run {
expr, handlers, .. expr, handlers, ..
} => { } => {

View File

@@ -598,6 +598,9 @@ impl Formatter {
Expr::Field { object, field, .. } => { Expr::Field { object, field, .. } => {
format!("{}.{}", self.format_expr(object), field.name) format!("{}.{}", self.format_expr(object), field.name)
} }
Expr::TupleIndex { object, index, .. } => {
format!("{}.{}", self.format_expr(object), index)
}
Expr::If { condition, then_branch, else_branch, .. } => { Expr::If { condition, then_branch, else_branch, .. } => {
format!( format!(
"if {} then {} else {}", "if {} then {} else {}",

View File

@@ -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, .. } => { Expr::Lambda { params, body, .. } => {
let closure = Closure { let closure = Closure {
params: params.iter().map(|p| p.name.name.clone()).collect(), params: params.iter().map(|p| p.name.name.clone()).collect(),

View File

@@ -510,7 +510,7 @@ impl Linter {
self.collect_refs_expr(&arm.body); self.collect_refs_expr(&arm.body);
} }
} }
Expr::Field { object, .. } => { Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => {
self.collect_refs_expr(object); self.collect_refs_expr(object);
} }
Expr::Record { fields, .. } => { Expr::Record { fields, .. } => {

View File

@@ -1576,7 +1576,7 @@ fn collect_call_site_hints(
collect_call_site_hints(source, e, param_names, 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); collect_call_site_hints(source, object, param_names, hints);
} }
Expr::Run { expr, handlers, .. } => { Expr::Run { expr, handlers, .. } => {

View File

@@ -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 // Exhaustiveness checking tests
mod exhaustiveness_tests { mod exhaustiveness_tests {
use super::*; use super::*;

View File

@@ -1646,6 +1646,20 @@ impl Parser {
} else if self.check(TokenKind::Dot) { } else if self.check(TokenKind::Dot) {
let start = expr.span(); let start = expr.span();
self.advance(); 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()?; let field = self.parse_ident()?;
// Check if this is an effect operation: Effect.operation(args) // Check if this is an effect operation: Effect.operation(args)
@@ -1681,11 +1695,14 @@ impl Parser {
fn parse_args(&mut self) -> Result<Vec<Expr>, ParseError> { fn parse_args(&mut self) -> Result<Vec<Expr>, ParseError> {
let mut args = Vec::new(); let mut args = Vec::new();
self.skip_newlines();
while !self.check(TokenKind::RParen) { while !self.check(TokenKind::RParen) {
args.push(self.parse_expr()?); args.push(self.parse_expr()?);
self.skip_newlines();
if !self.check(TokenKind::RParen) { if !self.check(TokenKind::RParen) {
self.expect(TokenKind::Comma)?; self.expect(TokenKind::Comma)?;
self.skip_newlines();
} }
} }

View File

@@ -484,7 +484,7 @@ impl SymbolTable {
self.visit_expr(arg, scope_idx); self.visit_expr(arg, scope_idx);
} }
} }
Expr::Field { object, .. } => { Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => {
self.visit_expr(object, scope_idx); self.visit_expr(object, scope_idx);
} }
Expr::If { condition, then_branch, else_branch, .. } => { Expr::If { condition, then_branch, else_branch, .. } => {

View File

@@ -335,7 +335,7 @@ fn references_params(expr: &Expr, params: &[&str]) -> bool {
Statement::Expr(e) => references_params(e, params), Statement::Expr(e) => references_params(e, params),
}) || references_params(result, 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::Lambda { body, .. } => references_params(body, params),
Expr::Tuple { elements, .. } => elements.iter().any(|e| references_params(e, params)), Expr::Tuple { elements, .. } => elements.iter().any(|e| references_params(e, params)),
Expr::List { 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, .. } => { Expr::Record { fields, .. } => {
fields.iter().any(|(_, e)| has_recursive_calls(func_name, e)) 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, .. } => { Expr::Let { value, body, .. } => {
has_recursive_calls(func_name, value) || has_recursive_calls(func_name, body) has_recursive_calls(func_name, value) || has_recursive_calls(func_name, body)
} }
@@ -1673,6 +1673,42 @@ impl TypeChecker {
span, span,
} => self.infer_field(object, field, *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 { Expr::Lambda {
params, params,
return_type, return_type,

View File

@@ -2040,7 +2040,9 @@ pub fn unify(t1: &Type, t2: &Type) -> Result<Substitution, String> {
// Function's required effects (e1) must be a subset of available effects (e2) // Function's required effects (e1) must be a subset of available effects (e2)
// A pure function (empty effects) can be called anywhere // A pure function (empty effects) can be called anywhere
// A function requiring {Logger} can be called in context with {Logger} or {Logger, Console} // 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!( return Err(format!(
"Effect mismatch: expected {{{}}}, got {{{}}}", "Effect mismatch: expected {{{}}}, got {{{}}}",
e1, e2 e1, e2