Project 5 — First-Class Functions: #2
Conversation
…e lambda disambiguation
… for lambda expressions
…alls and correct function type checking
There was a problem hiding this comment.
Pull request overview
This PR extends the MiniC language implementation to support first-class functions, including function types (fn(...) -> ...), anonymous functions (lambdas), and calling function values via expressions (closures with lexical capture), with accompanying parser/type-checker/interpreter updates and new tests.
Changes:
- Added AST and parser support for function types, lambdas (
Expr::Lambda), and expression-based calls (Expr::CallExpr). - Updated the type checker and interpreter to handle function values/closures and calling via expressions.
- Added/updated tests for function type parsing/integration plus lambda/closure behavior.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/ir/ast.rs |
Adds AST nodes for lambdas and call-by-expression; makes declaration initializer optional. |
src/parser/identifiers.rs |
Reserves fn keyword. |
src/parser/functions.rs |
Adds parsing for function types (fn(...) -> ...). |
src/parser/expressions.rs |
Adds lambda parsing and parses calls as postfix on any primary expression. |
src/parser/statements.rs |
Allows Type ident [= expr]; declarations; exports block_statement for lambda parsing. |
src/semantic/type_checker.rs |
Adds typing for lambdas/call-by-expression and function-type compatibility; changes decl-init handling. |
src/interpreter/value.rs |
Adds FnValue::Closure and equality/debug behavior for closures. |
src/interpreter/eval_expr.rs |
Evaluates lambdas into closures, supports expression-based calls, and introduces eval_call_value. |
src/interpreter/exec_stmt.rs |
Changes declaration execution to require an initializer at runtime. |
tests/type_function_tests.rs |
New unit tests for parsing function types via type_name. |
tests/type_function_integration_tests.rs |
New parser integration tests for function types in declarations and function signatures. |
tests/type_checker.rs |
Adds lambda type-checking tests and updates decl-init expectations. |
tests/parser.rs |
Updates decl test for optional initializer and tweaks an index-pattern match. |
tests/interpreter.rs |
Adds lambda/closure interpreter tests and a new helper run_full. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let init = init.as_ref().ok_or_else(|| { | ||
| RuntimeError::new(format!( | ||
| "variable '{}' declared without initializer", | ||
| name | ||
| )) | ||
| })?; | ||
| let val = eval_expr(init, env)?; |
There was a problem hiding this comment.
Runtime now errors immediately on Decl without initializer. Since the parser/AST allow Type ident ;, this prevents the intended workflow of declaring a variable and assigning later (and also blocks fn(...) -> ... f; as documented in the AST). If uninitialized declarations are meant to be supported, declare the variable with a sentinel (e.g., Value::Uninitialized) and raise an error only when the variable is read/called before assignment.
| let init = init.as_ref().ok_or_else(|| { | |
| RuntimeError::new(format!( | |
| "variable '{}' declared without initializer", | |
| name | |
| )) | |
| })?; | |
| let val = eval_expr(init, env)?; | |
| let val = match init.as_ref() { | |
| Some(init) => eval_expr(init, env)?, | |
| None => Value::Uninitialized, | |
| }; |
| // avoid confusing function types with lambdas | ||
| if rest.trim_start().starts_with("{") { | ||
| return Err(nom::Err::Error(nom::error::Error::new( | ||
| rest, | ||
| nom::error::ErrorKind::Tag, | ||
| ))); | ||
| } |
There was a problem hiding this comment.
fun_type rejects a function type if the remaining input starts with { to avoid confusing types with lambdas. This also rejects valid nested function types in contexts where a { legitimately follows a type, e.g. parsing a lambda that returns a function type: fn() -> fn(int)->int { ... } (the return type parse will see { and error). A safer disambiguation is to allow fun_type unconditionally and resolve the fn(...) -> ... { ... } vs fn(...) -> ... ambiguity in the expression parser (by trying lambda_expr before other atoms) and/or in the surrounding statement grammar, rather than inside type_name.
| fn eval_call_value( | ||
| callee: Value, | ||
| args: Vec<Value>, | ||
| env: &mut Environment<Value>, | ||
| ) -> Result<Value, RuntimeError> { | ||
| match callee { | ||
| Value::Fn(FnValue::Native(f)) => (f)(args), | ||
|
|
||
| Value::Fn(FnValue::UserDefined(decl)) => { | ||
| if args.len() != decl.params.len() { | ||
| return Err(RuntimeError::new(format!( | ||
| "function '{}' expects {} arguments, got {}", | ||
| decl.name, | ||
| decl.params.len(), | ||
| args.len() | ||
| ))); | ||
| } | ||
| let snapshot = env.snapshot(); | ||
| for ((param_name, _), val) in decl.params.iter().zip(args.into_iter()) { | ||
| env.declare(param_name.clone(), val); | ||
| } | ||
| let result = exec_stmt(&decl.body, env)?; | ||
| env.restore(snapshot); | ||
| Ok(result.unwrap_or(Value::Void)) | ||
| } |
There was a problem hiding this comment.
eval_call_value supports FnValue::Closure, but eval_call (used by call-statements and as the interpreter entrypoint for main) does not. As a result, a closure stored in a variable will work in expression position (print(f(1)) via Expr::Call), but will fail at runtime as a statement (f(1); becomes Statement::Call and routes through eval_call). Consider implementing the Closure branch in eval_call as well, or refactoring eval_call to just look up the callee in the environment and delegate to eval_call_value.
| match (self, other) { | ||
| (FnValue::UserDefined(a), FnValue::UserDefined(b)) => a == b, | ||
| (FnValue::Native(a), FnValue::Native(b)) => (*a as usize) == (*b as usize), | ||
| (FnValue::Closure { decl: da, .. }, FnValue::Closure { decl: db, .. }) => da == db, |
There was a problem hiding this comment.
FnValue::Closure equality compares only decl and ignores captured. This means two closures created from the same lambda body but with different captured values will compare equal (and Value derives PartialEq, so == uses this). With Type::Fun now considered compatible, function equality can be type-checked, so this can produce incorrect runtime results. Consider either (1) disallowing Eq/Ne on function values at the type-checker level, or (2) defining closure equality as identity (e.g., unique id) / include captured environment in the comparison.
| (FnValue::Closure { decl: da, .. }, FnValue::Closure { decl: db, .. }) => da == db, | |
| ( | |
| FnValue::Closure { | |
| decl: da, | |
| captured: ca, | |
| }, | |
| FnValue::Closure { | |
| decl: db, | |
| captured: cb, | |
| }, | |
| ) => da == db && ca == cb, |
| /// Chamada de função por expressão: chmd(args) | ||
| /// apenas para não mexer em Call, mas depois podemos mesclar os dois (Call pode ser um caso especial de CallExpr onde callee é um Ident). | ||
| /// Ex.: 'f(42)', '(funçãolambda)(42)', etc.) | ||
| CallExpr { | ||
| chmd: Box<ExprD<Ty>>, | ||
| args: Vec<ExprD<Ty>>, | ||
| }, | ||
|
|
||
| /// Função Lambda: `fn(params) -> return_tipo { crp }` | ||
| /// regra pra não ficar ambiguo: | ||
| /// 'fn(...) -> ...' é tipo, ou seja 'Type::Fun' | ||
| /// 'fn(...) -> ... { ... }' é expressão, ou seja 'Expr::Lambda' | ||
| Lambda { | ||
| params: Vec<Param>, | ||
| return_tipo: Type, | ||
| crp: Box<StatementD<Ty>>, |
There was a problem hiding this comment.
New AST fields use abbreviated/Portuguese names (chmd, crp, return_tipo) which are hard to understand and inconsistent with the rest of the AST (name, args, return_type, etc.). Renaming to clearer identifiers like callee, body/block, and return_type will make the parser/type-checker/interpreter code easier to follow and reduce future mistakes.
| /// Chamada de função por expressão: chmd(args) | |
| /// apenas para não mexer em Call, mas depois podemos mesclar os dois (Call pode ser um caso especial de CallExpr onde callee é um Ident). | |
| /// Ex.: 'f(42)', '(funçãolambda)(42)', etc.) | |
| CallExpr { | |
| chmd: Box<ExprD<Ty>>, | |
| args: Vec<ExprD<Ty>>, | |
| }, | |
| /// Função Lambda: `fn(params) -> return_tipo { crp }` | |
| /// regra pra não ficar ambiguo: | |
| /// 'fn(...) -> ...' é tipo, ou seja 'Type::Fun' | |
| /// 'fn(...) -> ... { ... }' é expressão, ou seja 'Expr::Lambda' | |
| Lambda { | |
| params: Vec<Param>, | |
| return_tipo: Type, | |
| crp: Box<StatementD<Ty>>, | |
| /// Function call through an expression: callee(args) | |
| /// Kept separate from `Call` for now, though the two could be merged later | |
| /// (`Call` can be treated as a special case of `CallExpr` where `callee` | |
| /// is an `Ident`). Examples: `f(42)`, `(lambda_fn)(42)`, etc. | |
| CallExpr { | |
| callee: Box<ExprD<Ty>>, | |
| args: Vec<ExprD<Ty>>, | |
| }, | |
| /// Lambda function: `fn(params) -> return_type { body }` | |
| /// To avoid ambiguity: | |
| /// `fn(...) -> ...` is a type, i.e. `Type::Fun` | |
| /// `fn(...) -> ... { ... }` is an expression, i.e. `Expr::Lambda` | |
| Lambda { | |
| params: Vec<Param>, | |
| return_type: Type, | |
| body: Box<StatementD<Ty>>, |
| /// Executa o pipeline completo: Parser -> Type Check -> Interpret | ||
| fn run_full(src: &str) -> Result<(), String> { | ||
| let (_, unchecked) = program(src).map_err(|e| format!("Parser Error: {:?}", e))?; | ||
|
|
||
| // Passando &unchecked (Referência) | ||
| let checked = type_check(&unchecked).map_err(|e| format!("Type Error: {}", e.message))?; | ||
|
|
||
| // Execução | ||
| interpret(&checked).map_err(|e| format!("Runtime Error: {}", e.message))?; | ||
|
|
||
| Ok(()) |
There was a problem hiding this comment.
run_full duplicates the existing run helper at the top of this file (same pipeline: parse → type_check → interpret) and adds a second set of slightly different error strings. This duplication makes future test maintenance harder; consider reusing run directly or extending it to cover the desired error formatting.
| /// Executa o pipeline completo: Parser -> Type Check -> Interpret | |
| fn run_full(src: &str) -> Result<(), String> { | |
| let (_, unchecked) = program(src).map_err(|e| format!("Parser Error: {:?}", e))?; | |
| // Passando &unchecked (Referência) | |
| let checked = type_check(&unchecked).map_err(|e| format!("Type Error: {}", e.message))?; | |
| // Execução | |
| interpret(&checked).map_err(|e| format!("Runtime Error: {}", e.message))?; | |
| Ok(()) | |
| /// Executa o pipeline completo reutilizando o helper padrão de testes. | |
| fn run_full(src: &str) -> Result<(), String> { | |
| run(src) |
| ); | ||
| } | ||
|
|
||
| #[test] |
There was a problem hiding this comment.
The added interpreter tests cover calling closures in expression position (print(somar(50))), but they don't cover calling a closure as a statement (somar(50);). Given Statement::Call currently routes through eval_call, this is an important case to test to prevent regressions once closure dispatch is unified for statements and expressions.
| #[test] | |
| #[test] | |
| fn test_exec_closure_call_as_statement() { | |
| // Garante cobertura para chamada de closure em posição de statement: somar(50); | |
| let src = r#" | |
| void main() { | |
| int base = 100; | |
| fn(int) -> void somar = fn(int n) -> void { print(n + base); }; | |
| somar(50); | |
| } | |
| "#; | |
| let result = run_full(src); | |
| assert!( | |
| result.is_ok(), | |
| "A chamada de closure em posição de statement falhou. Erro: {:?}", | |
| result.err() | |
| ); | |
| } | |
| #[test] |
| let init = init.as_ref().ok_or_else(|| { | ||
| TypeError::new(format!( | ||
| "variable '{}' must be initialized", | ||
| name | ||
| )) | ||
| })?; |
There was a problem hiding this comment.
Statement::Decl now rejects missing initializers (init: None) at type-check time, but the parser/AST were changed to allow Type ident ; (and even documented in ast.rs for fn(...) -> ... f;). This makes programs with declarations without init untypecheckable and contradicts the intended “declare first, initialize before use” behavior. Consider allowing init: None here (at least for function-typed vars), declaring the name in env, and tracking initialization state separately so reads/calls can error if used before assignment.
Grupo:
Segue a baixo a descrição pelo copilot.
This pull request adds support for first-class and anonymous functions (lambdas), function values, and function calls via expressions, including closures with lexical scoping. It also updates the parser, type checker, and interpreter to handle these new language features, and refines variable declaration and initialization rules.
Support for first-class functions and lambdas:
Expr::Lambda) and function calls via expressions (Expr::CallExpr), enabling anonymous functions and higher-order programming. [1] [2]fn. [1] [2] [3] [4] [5] [6]Interpreter and runtime changes:
Closurevariant toFnValueto represent closures with captured environments, and implemented appropriate equality and debug formatting. [1] [2] [3]Variable declaration and initialization:
Function type parsing and compatibility:
fn(...) -> ...) as type annotations, with checks to avoid confusion with lambdas. Improved type compatibility checks for function types. [1] [2]These changes together provide robust support for functional programming constructs in the language, including lambdas, closures, and first-class function values.