Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion docs/design/grob-v1-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@ closures.
the type checker narrows `x` from `T?` to `T`.

**Acceptance:** Functions call and return correctly. Lambdas work in
`filter`, `map`, `sort`. Closures capture enclosing variables. Named
`filter`, `select`, `sort`. Closures capture enclosing variables. Named
parameters work. The type checker catches arity mismatches and type
mismatches on arguments.

Expand Down
151 changes: 148 additions & 3 deletions src/Grob.Compiler/Compiler.Expressions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,131 @@
return null;
}

// -----------------------------------------------------------------------
// Lambda expression (Sprint 5 Increment C — categories 1–3 only)
// -----------------------------------------------------------------------

/// <inheritdoc/>
/// <remarks>
/// Compiles the lambda into its own <see cref="BytecodeFunction"/> (using the same
/// sub-compiler pattern as <see cref="VisitFnDecl"/>) and emits a single
/// <see cref="OpCode.Constant"/> in the enclosing chunk so the value is pushed onto
/// the stack. The lambda is then an opaque callable that the caller stores, passes,
/// or immediately uses as an argument.
///
/// <para><b>Category 1–3 resolution.</b> The sub-compiler inherits the root's
/// <c>_constValues</c> cache, so top-level <c>const</c> references inside the body
/// are inlined (category 1). Any other identifier resolves via
/// <see cref="EmitLoad"/>, which falls through to <see cref="OpCode.GetGlobal"/> for
/// names not found in the sub-compiler's local scopes — correct for top-level
/// <c>readonly</c> (category 2) and mutable (category 3). Writes inside block-body
/// lambdas similarly emit <see cref="OpCode.SetGlobal"/>. Category 4 (enclosing-
/// function-local capture) is Increment D; no upvalue opcodes are emitted here.</para>
///
/// <para><b>Block-body return semantics (D-276).</b> When the body is a
/// <see cref="LambdaBlockBody"/>, compilation proceeds statement-by-statement. The
/// last statement is special: if it is an <see cref="ExpressionStmt"/>, its inner
/// expression is compiled without the trailing <see cref="OpCode.Pop"/> so the value
/// stays on the stack as the implicit return value, and <see cref="OpCode.Return"/>
/// follows immediately. Any other last statement compiles normally and falls through
/// to the safety-net <see cref="OpCode.Nil"/> + <see cref="OpCode.Return"/>.</para>
/// </remarks>
public override object? VisitLambda(LambdaExpr node) {
int line = node.Range.Start.Line;
var sub = new Compiler(_constValues);

// Lambda parameters occupy the first local slots (slot 0, 1, …).
sub._localScopes.Push([]);
foreach (Parameter p in node.Parameters) {
if ((uint)sub._nextSlot > byte.MaxValue)
throw new GrobInternalException(
$"Parameter slot overflow: lambda exceeds the 1-byte slot limit of {byte.MaxValue}.");
sub._localScopes.Peek().Add(new LocalVar(p.Name, sub._nextSlot++));
}

switch (node.Body) {
case LambdaExpressionBody exprBody:
if (IsBuiltinVoidCall(exprBody.Expression)) {
// print/exit are void — no return value on the stack. Route via
// VisitExpressionStmt (which has the built-in opcode mapping) so the
// lambda's chunk gets the correct Print/Exit opcode rather than a
// GetGlobal. The safety-net Nil+Return below supplies nil as the
// implicit return value (which callers like 'each' discard).
sub.VisitExpressionStmt(
new ExpressionStmt(exprBody.Expression.Range, exprBody.Expression));
// Fall through to safety-net Nil+Return.
} else {
// Non-void expression body: value stays on stack → Return.
sub.Visit(exprBody.Expression);
sub._chunk.WriteOpCode(OpCode.Return, line);
}
break;

case LambdaBlockBody blockBody:
CompileLambdaBlock(sub, blockBody, line);
break;
}

// Safety-net return: a block body (or empty expression body path) that does not
// return on every path falls through to here and returns nil.
sub._chunk.WriteOpCode(OpCode.Nil, line);
sub._chunk.WriteOpCode(OpCode.Return, line);

var fn = new BytecodeFunction(string.Empty, node.Parameters.Count, sub._chunk);
EmitConstant(GrobValue.FromFunction(fn), line);
return null;
}

/// <summary>
/// Compiles the statements of a <see cref="LambdaBlockBody"/> into
/// <paramref name="sub"/>'s chunk with D-276 semantics: the last statement's
/// expression (if it is an <see cref="ExpressionStmt"/>) is compiled without a
/// trailing <see cref="OpCode.Pop"/> so it stays on the stack as the implicit
/// return value, followed by <see cref="OpCode.Return"/>. All other last statements
/// compile normally and fall through to the caller's safety-net Nil + Return.
/// </summary>
private static void CompileLambdaBlock(Compiler sub, LambdaBlockBody blockBody, int endLine) {

Check warning on line 266 in src/Grob.Compiler/Compiler.Expressions.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Remove this unused method parameter 'endLine'.

Check warning on line 266 in src/Grob.Compiler/Compiler.Expressions.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused method parameter 'endLine'.

See more on https://sonarcloud.io/project/issues?id=grob-lang_grob&issues=AZ7vI9nyIn8fCMBI5L82&open=AZ7vI9nyIn8fCMBI5L82&pullRequest=85
// Open a scope for block-body locals (mirrors VisitBlock but without a PopN at
// the end — Return handles frame cleanup in the VM).
sub._localScopes.Push([]);

IReadOnlyList<AstNode> stmts = blockBody.Block.Statements;
if (stmts.Count == 0) {
sub._localScopes.Pop();
return; // safety-net Nil+Return is emitted by the caller
}

// Compile all statements except the last normally.
for (int i = 0; i < stmts.Count - 1; i++)
sub.Visit(stmts[i]);

// Compile the last statement with D-276 semantics.
AstNode last = stmts[stmts.Count - 1];
if (last is ExpressionStmt exprStmt) {
int stmtLine = exprStmt.Range.Start.Line;
if (IsBuiltinVoidCall(exprStmt.Expression)) {
// print/exit are void — emit via VisitExpressionStmt (which carries the
// built-in opcode mapping) so the chunk gets Print/Exit rather than a
// GetGlobal. No explicit Return needed — fall through to safety-net.
sub.VisitExpressionStmt(exprStmt);
// Fall through to caller's safety-net Nil+Return.
} else {
// Non-void expression: skip VisitExpressionStmt (which would emit Pop)
// and visit the inner expression directly so the value stays on the stack.
sub.Visit(exprStmt.Expression);
sub._chunk.WriteOpCode(OpCode.Return, stmtLine);
}
} else {
sub.Visit(last);
// Fall through to caller's safety-net.
}

// Pop the block-body scope from the compiler's scope stack. The VM's Return
// handles the actual runtime stack cleanup; PopN is only needed for fall-through
// paths inside the block, none of which exist after an explicit Return.
sub._localScopes.Pop();
}

/// <summary>
/// Returns the cached <see cref="GrobValue"/> for a <see cref="ConstDecl"/>.
/// <see cref="VisitConstDecl"/> always caches the value before any reference site is
Expand Down Expand Up @@ -329,9 +454,14 @@
private void EmitArithmetic(BinaryExpr node, GrobType lt, GrobType rt, int line) {
bool leftNeedsCoerce = lt == GrobType.Int && rt == GrobType.Float;
bool rightNeedsCoerce = rt == GrobType.Int && lt == GrobType.Float;
GrobType resultType = (lt == GrobType.Float || rt == GrobType.Float)
? GrobType.Float
: lt;
// resultType: Float if either operand is float; Unknown operands (lambda
// parameters) default to Int — the same optimistic convention used in
// ComparisonCategory. A lambda that passes a float array gets a VM-level
// type fault; typed parameter inference (Increment D) will fix this.
GrobType baseType = (lt == GrobType.Float || rt == GrobType.Float) ? GrobType.Float : lt;
GrobType resultType = baseType == GrobType.Unknown
? (rt == GrobType.Float ? GrobType.Float : GrobType.Int)

Check warning on line 463 in src/Grob.Compiler/Compiler.Expressions.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Extract this nested ternary operation into an independent statement.

Check warning on line 463 in src/Grob.Compiler/Compiler.Expressions.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=grob-lang_grob&issues=AZ7vI9nyIn8fCMBI5L83&open=AZ7vI9nyIn8fCMBI5L83&pullRequest=85
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
: baseType;

Visit(node.Left);
if (leftNeedsCoerce) {
Expand Down Expand Up @@ -787,4 +917,19 @@
if (left == GrobType.String) return GrobType.String;
return left;
}

/// <summary>
/// Returns <see langword="true"/> when <paramref name="expr"/> is a call to a
/// built-in void function (<c>print</c> or <c>exit</c>).
///
/// <para>These built-ins are handled by <see cref="VisitExpressionStmt"/> and
/// emit dedicated VM opcodes (<see cref="OpCode.Print"/> / <see cref="OpCode.Exit"/>)
/// rather than a <see cref="OpCode.GetGlobal"/> + <see cref="OpCode.Call"/>. In
/// expression context (e.g. a lambda expression body) the caller must route through
/// <see cref="VisitExpressionStmt"/> so the correct opcode is emitted; the safety-net
/// <see cref="OpCode.Nil"/> + <see cref="OpCode.Return"/> in <see cref="VisitLambda"/>
/// supplies the implicit nil return value (which <c>each</c>-style callers discard).</para>
/// </summary>
private static bool IsBuiltinVoidCall(Expression expr) =>
expr is CallExpr { Callee: IdentifierExpr { Name: "print" or "exit" } };
}
145 changes: 136 additions & 9 deletions src/Grob.Compiler/TypeChecker.Expressions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ public override GrobType VisitUnary(UnaryExpr node) {
UnaryOperator.Negate when operand == GrobType.Int => GrobType.Int,
UnaryOperator.Negate when operand == GrobType.Float => GrobType.Float,
UnaryOperator.Not when operand == GrobType.Bool => GrobType.Bool,
// Unknown operand (e.g. lambda parameter) — be permissive; propagate Unknown.
_ when operand == GrobType.Unknown => GrobType.Unknown,
UnaryOperator.Negate => EmitErrorAndReturn(ErrorCatalog.E0002,
$"Operator '-' cannot be applied to type '{TypeName(operand)}'.", node.Range),
UnaryOperator.Not => EmitErrorAndReturn(ErrorCatalog.E0002,
Expand Down Expand Up @@ -134,6 +136,12 @@ private GrobType ResolveArithmetic(BinaryExpr node, GrobType left, GrobType righ
return GrobType.Float;
}

// One or both operands are of unknown type (e.g. a lambda parameter whose type
// is inferred at the call site, or a deferred member type). Be permissive and
// propagate Unknown — the VM will validate types at runtime. This mirrors the
// Unknown pass-through in ResolveComparison below.
if (left == GrobType.Unknown || right == GrobType.Unknown) return GrobType.Unknown;

// All other combinations are type errors — e.g. int + string.
return EmitErrorAndReturn(ErrorCatalog.E0002,
$"Operator '{OperatorSymbol(node.Operator)}' cannot be applied to types '{TypeName(left)}' and '{TypeName(right)}'.",
Expand Down Expand Up @@ -242,10 +250,27 @@ private GrobType ResolveNilCoalesce(BinaryExpr node, GrobType left, GrobType rig
/// type. Built-in and unresolved callees stay permissive.
/// </remarks>
public override GrobType VisitCall(CallExpr node) {
// Sprint 5C: array higher-order method calls (filter/select/sort/each).
// The callee is a member access on an array receiver; we visit the target
// directly (not the whole MemberAccessExpr) so we can branch on the receiver type
// without a double-visit.
if (node.Callee is MemberAccessExpr memberAccess) {
GrobType receiverType = Visit(memberAccess.Target);
// Visit argument values to satisfy §3.1.1 on any identifiers inside them.
var argTypes = new GrobType[node.Arguments.Count];
for (int i = 0; i < node.Arguments.Count; i++)
argTypes[i] = Visit(node.Arguments[i].Value);

if (receiverType == GrobType.Array && IsArrayHigherOrderMethod(memberAccess.Member)) {
return ValidateArrayMethodCall(node, memberAccess.Member, argTypes);
}
return GrobType.Unknown;
}

Visit(node.Callee);
var argTypes = new GrobType[node.Arguments.Count];
var callArgTypes = new GrobType[node.Arguments.Count];
for (int i = 0; i < node.Arguments.Count; i++) {
argTypes[i] = Visit(node.Arguments[i].Value);
callArgTypes[i] = Visit(node.Arguments[i].Value);
}

// Only user-defined functions are checked positionally here. Built-ins
Expand All @@ -254,10 +279,54 @@ public override GrobType VisitCall(CallExpr node) {
return GrobType.Unknown;
}

CheckCall(node, fn, argTypes);
CheckCall(node, fn, callArgTypes);
return ResolveTypeRef(fn.ReturnType);
}

private static bool IsArrayHigherOrderMethod(string name) =>
name is "filter" or "select" or "sort" or "each";

/// <summary>
/// Validates an array higher-order method call and returns the result type.
/// Emits E0004 when a <c>filter</c> predicate's inferred return type is known
/// to be non-bool (neither <see cref="GrobType.Unknown"/> nor
/// <see cref="GrobType.Error"/> — those are permissive).
/// </summary>
private GrobType ValidateArrayMethodCall(
CallExpr node, string methodName, GrobType[] argTypes) {
switch (methodName) {
case "filter": {
// First argument must be a predicate returning bool.
if (node.Arguments.Count >= 1 &&
node.Arguments[0].Value is LambdaExpr lambdaPred &&
_lambdaReturnTypes.TryGetValue(lambdaPred, out GrobType bodyType) &&
bodyType != GrobType.Unknown && bodyType != GrobType.Error &&
bodyType != GrobType.Bool) {
EmitError(ErrorCatalog.E0004,
$"'filter' predicate must return 'bool'; found '{TypeName(bodyType)}'.",
node.Arguments[0].Value.Range);
}
return GrobType.Array;
}
case "select":
return GrobType.Array;
case "sort":
// Optional second arg must be bool (the 'descending' flag).
if (node.Arguments.Count >= 2 &&
argTypes[1] != GrobType.Bool && argTypes[1] != GrobType.Unknown &&
argTypes[1] != GrobType.Error) {
EmitError(ErrorCatalog.E0004,
$"'sort' second argument ('descending') must be 'bool'; found '{TypeName(argTypes[1])}'.",
node.Arguments[1].Value.Range);
}
return GrobType.Array;
case "each":
return GrobType.Unknown; // void
default:
return GrobType.Unknown;
}
}

/// <summary>
/// Validates a call against a resolved <paramref name="fn"/> under the D-113
/// calling convention: positional arguments first, then named arguments, with
Expand Down Expand Up @@ -693,12 +762,70 @@ public override GrobType VisitNumericRange(NumericRangeExpr node) {
}

/// <inheritdoc/>
// Deferred to Sprint 5 (grob-lang/grob#44): lambda scoping, parameter type
// inference, and closure capture are designed together. Partial traversal
// now would register inferred-type parameters as Unknown — non-null but
// semantically incorrect. Nothing downstream observes this gap today.
[ExcludeFromCodeCoverage(Justification = "Lambda type-checking is deferred to Sprint 5 (grob-lang/grob#44).")]
public override GrobType VisitLambda(LambdaExpr node) => GrobType.Unknown;
/// <remarks>
/// Sprint 5 Increment C — D-296 categories 1–3 only (top-level references).
///
/// Registers each lambda parameter as <see cref="GrobType.Unknown"/> (inferred)
/// with the <see cref="LambdaExpr"/> as its declaring node, satisfying the §3.1.1
/// invariant: every identifier that resolves to a parameter carries a non-null
/// <see cref="IdentifierExpr.Declaration"/>. The body is then visited in the
/// parameter scope so all nested identifier nodes are resolved.
///
/// The inferred body return type is stored in
/// <see cref="_lambdaReturnTypes"/> keyed by this node, so
/// <see cref="ValidateArrayMethodCall"/> can check predicate types (E0004 on
/// <c>filter</c>). Category-4 upvalue capture is Increment D.
/// </remarks>
public override GrobType VisitLambda(LambdaExpr node) {
// Open a scope for the lambda's parameters.
_scopes.Push(new Dictionary<string, Symbol>());
foreach (Parameter p in node.Parameters) {
// Lambda parameter types are inferred (not declared), so register as Unknown.
// Use the LambdaExpr as the declaring node — Parameter is not an AstNode.
RegisterSymbol(p.Name, GrobType.Unknown, p.Range.Start, node);
}

// Track a return-type context so VisitReturn can validate early-return stmts
// inside a block-body lambda (E0005) and distinguish them from top-level
// returns (E2203).
_functionReturnTypes.Push(GrobType.Unknown);

// Infer the body type and store it for callers (e.g. ValidateArrayMethodCall).
GrobType bodyType = node.Body switch {
LambdaExpressionBody exprBody => Visit(exprBody.Expression),
LambdaBlockBody blockBody => VisitLambdaBlock(blockBody),
_ => GrobType.Unknown,
};
_lambdaReturnTypes[node] = bodyType;

_functionReturnTypes.Pop();
_scopes.Pop();

// Return Unknown for the lambda value's own type — there is no GrobType.Function
// variant in this increment. Callers that need the body type use _lambdaReturnTypes.
return GrobType.Unknown;
}

/// <summary>
/// Visits all statements in a block-body lambda and infers the return type.
/// Returns the type of the last <see cref="ExpressionStmt"/>'s expression (the
/// implicit last-expression result per D-276), or <see cref="GrobType.Nil"/> when
/// the last statement is not an expression.
/// </summary>
private GrobType VisitLambdaBlock(LambdaBlockBody blockBody) {
IReadOnlyList<AstNode> stmts = blockBody.Block.Statements;
if (stmts.Count == 0) return GrobType.Nil;

for (int i = 0; i < stmts.Count - 1; i++)
Visit(stmts[i]);

// The last statement determines the implicit return type.
AstNode last = stmts[stmts.Count - 1];
if (last is ExpressionStmt exprStmt)
return Visit(exprStmt.Expression); // implicit return value; don't emit Pop
Visit(last);
return GrobType.Nil;
}

// -----------------------------------------------------------------------
// Internal guards
Expand Down
Loading
Loading