diff --git a/examples/0054-basic-dot-call-default-args.sx b/examples/0054-basic-dot-call-default-args.sx new file mode 100644 index 0000000..e998fe6 --- /dev/null +++ b/examples/0054-basic-dot-call-default-args.sx @@ -0,0 +1,33 @@ +// Trailing parameter defaults fill on method and ufcs dot-calls (the +// receiver-prepending dispatch paths), matching bare-call expansion (0044); +// a `#caller_location` default and a slice variadic keep their flexible +// arity under the call-arity check. +// Regression (issue 0123). + +#import "modules/std.sx"; + +Point :: struct { + x: s64; + scaled :: (self: Point, k: s64 = 2) -> s64 { return self.x * k; } +} + +bump :: ufcs (p: Point, by: s64 = 10) -> s64 { return p.x + by; } + +sum_var :: (..xs: []s64) -> s64 { + t := 0; + for xs (x) { t = t + x; } + return t; +} + +here :: (loc: Source_Location = #caller_location) -> s64 { return loc.line; } + +main :: () { + p := Point.{ x = 5 }; + print("{}\n", p.scaled()); // default filled on method dispatch + print("{}\n", p.scaled(3)); // explicit overrides + print("{}\n", p.bump()); // default filled on ufcs dispatch + print("{}\n", p.bump(1)); + print("{}\n", sum_var()); // variadic: zero args + print("{}\n", sum_var(1, 2, 3)); // variadic: many args + print("{}\n", here() > 0); // #caller_location default +} diff --git a/examples/1167-diagnostics-call-arity-mismatch.sx b/examples/1167-diagnostics-call-arity-mismatch.sx new file mode 100644 index 0000000..2904546 --- /dev/null +++ b/examples/1167-diagnostics-call-arity-mismatch.sx @@ -0,0 +1,26 @@ +// Wrong argument counts to fixed-arity functions are rejected at the call +// site — bare calls, flat-imported stdlib fns, method dot-calls, and ufcs +// dot-calls — instead of reaching LLVM verification ("Incorrect number of +// arguments passed to called function!"). +// Regression (issue 0123). + +#import "modules/std.sx"; + +add2 :: (a: s64, b: s64) -> s64 { return a + b; } + +Point :: struct { + x: s64; + scaled :: (self: Point, k: s64) -> s64 { return self.x * k; } +} + +bump :: ufcs (p: Point, by: s64) -> s64 { return p.x + by; } + +main :: () -> s32 { + _ = add2(1, 2, 3); // plain bare call, too many + _ = add2(1); // plain bare call, too few + _ = concat("a", "b", "c"); // flat-imported stdlib fn, too many + p := Point.{ x = 5 }; + _ = p.scaled(2, 9); // method dot-call, too many + _ = p.bump(1, 2); // ufcs dot-call, too many + return 0; +} diff --git a/examples/expected/0054-basic-dot-call-default-args.exit b/examples/expected/0054-basic-dot-call-default-args.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0054-basic-dot-call-default-args.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0054-basic-dot-call-default-args.stderr b/examples/expected/0054-basic-dot-call-default-args.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0054-basic-dot-call-default-args.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0054-basic-dot-call-default-args.stdout b/examples/expected/0054-basic-dot-call-default-args.stdout new file mode 100644 index 0000000..bd1a9f7 --- /dev/null +++ b/examples/expected/0054-basic-dot-call-default-args.stdout @@ -0,0 +1,7 @@ +10 +15 +15 +6 +0 +6 +true diff --git a/examples/expected/1167-diagnostics-call-arity-mismatch.exit b/examples/expected/1167-diagnostics-call-arity-mismatch.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1167-diagnostics-call-arity-mismatch.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1167-diagnostics-call-arity-mismatch.stderr b/examples/expected/1167-diagnostics-call-arity-mismatch.stderr new file mode 100644 index 0000000..c7acb4e --- /dev/null +++ b/examples/expected/1167-diagnostics-call-arity-mismatch.stderr @@ -0,0 +1,29 @@ +error: 'add2' expects 2 arguments, but 3 were given + --> examples/1167-diagnostics-call-arity-mismatch.sx:19:9 + | +19 | _ = add2(1, 2, 3); // plain bare call, too many + | ^^^^ + +error: 'add2' expects 2 arguments, but 1 was given + --> examples/1167-diagnostics-call-arity-mismatch.sx:20:9 + | +20 | _ = add2(1); // plain bare call, too few + | ^^^^ + +error: 'concat' expects 2 arguments, but 3 were given + --> examples/1167-diagnostics-call-arity-mismatch.sx:21:9 + | +21 | _ = concat("a", "b", "c"); // flat-imported stdlib fn, too many + | ^^^^^^ + +error: 'Point.scaled' expects 1 argument, but 2 were given + --> examples/1167-diagnostics-call-arity-mismatch.sx:23:9 + | +23 | _ = p.scaled(2, 9); // method dot-call, too many + | ^^^^^^^^ + +error: 'bump' expects 1 argument, but 2 were given + --> examples/1167-diagnostics-call-arity-mismatch.sx:24:9 + | +24 | _ = p.bump(1, 2); // ufcs dot-call, too many + | ^^^^^^ diff --git a/examples/expected/1167-diagnostics-call-arity-mismatch.stdout b/examples/expected/1167-diagnostics-call-arity-mismatch.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1167-diagnostics-call-arity-mismatch.stdout @@ -0,0 +1 @@ + diff --git a/issues/0123-call-arity-unchecked.md b/issues/0123-call-arity-unchecked.md index 4399fdb..a3e0560 100644 --- a/issues/0123-call-arity-unchecked.md +++ b/issues/0123-call-arity-unchecked.md @@ -1,5 +1,27 @@ # 0123 — wrong arg counts to fixed-arity fns reach LLVM emission +> **RESOLVED** (2026-06-12). Root cause: no dispatch path in `lowerCall` +> ever compared the supplied arg count against the callee's declared +> params (`coerceCallArgs` iterates `@min(args.len, params.len)`, so a +> mismatch sailed through to the LLVM verifier). Fix: a shared +> `checkCallArity` (src/ir/lower/call.zig) computes min (params without +> trailing defaults) / max (`params.len`, unbounded past a variadic) +> from the AST decl and rejects with a source-located diagnostic at the +> five plain dispatch sites — bare selected-author + lazy, namespace +> alias-gate + qualified, struct-method, ufcs. Pack / comptime / generic +> / `#compiler` / `#builtin` callees are exempt (own dispatch). The +> method/ufcs sites also gained the `appendDefaultArgs` fill the +> generic-instance leg already had — trailing defaults on dot-calls +> previously emitted under-arity calls (same verifier failure). Flushed +> out en route: `lowerStmt`'s `.fn_decl => |fd| ... (&fd)` registered a +> STACK address in `fn_ast_map`, so every local fn's map entry aliased +> the most recently lowered one — pointer capture (`|*fd|`) fixes it. +> Regressions: `examples/1167-diagnostics-call-arity-mismatch.sx` +> (too many / too few, bare + stdlib + method + ufcs) and +> `examples/0054-basic-dot-call-default-args.sx` (dot-call defaults, +> variadic, `#caller_location`). Gates: zig build test 426/426, suite +> 590/590 (fix in isolation), distribution repo 14/14. + ## Symptom Calling a fixed-arity function with the wrong number of arguments is diff --git a/src/ir/lower.zig b/src/ir/lower.zig index bc61391..492e7b0 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1800,6 +1800,7 @@ pub const Lowering = struct { pub const reflectionTypeArgGuard = lower_call.reflectionTypeArgGuard; pub const reflectionErrorSentinel = lower_call.reflectionErrorSentinel; pub const appendDefaultArgs = lower_call.appendDefaultArgs; + pub const checkCallArity = lower_call.checkCallArity; pub const expandCallDefaults = lower_call.expandCallDefaults; pub const userParamTypes = lower_call.userParamTypes; pub const resolveCallParamTypes = lower_call.resolveCallParamTypes; diff --git a/src/ir/lower/call.zig b/src/ir/lower/call.zig index 65ca495..f738d82 100644 --- a/src/ir/lower/call.zig +++ b/src/ir/lower/call.zig @@ -461,6 +461,15 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{func_name}); return Ref.none; } + // `args.items` is the post-expansion count: trailing defaults + // were filled by `expandCallDefaults`, comptime-pack spreads + // expanded element-wise above. + { + const arity_fd: ?*const ast.FnDecl = if (sel_author) |sf| sf.decl else self.program_index.fn_ast_map.get(func_name); + if (arity_fd) |fd| { + if (self.checkCallArity(fd, id.name, args.items.len, false, c.callee.span)) return Ref.none; + } + } if (sel_author) |sf| { const fid = self.selectedFuncId(sf, func_name); const func = &self.module.functions.items[@intFromEnum(fid)]; @@ -722,6 +731,7 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { } if (hasComptimeParams(fd)) return self.lowerComptimeCall(fd, c); if (fd.type_params.len > 0) return self.lowerGenericCall(fd, fa.field, c, args.items); + if (self.checkCallArity(fd, fa.field, args.items.len, false, c.callee.span)) return Ref.none; var sf = SelectedFunc{ .decl = fd, .source = target.target_module_path }; const fid = self.selectedFuncId(&sf, fa.field); const func = &self.module.functions.items[@intFromEnum(fid)]; @@ -763,6 +773,7 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { const ret_ty = func.ret; const params = func.params; if (self.program_index.fn_ast_map.get(effective_name)) |fd| { + if (self.checkCallArity(fd, effective_name, args.items.len, false, c.callee.span)) return Ref.none; self.packVariadicCallArgs(fd, c, &args); } const final_args = self.prependCtxIfNeeded(func, args.items); @@ -973,11 +984,12 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { } // Try non-generic qualified method - if (self.program_index.fn_ast_map.get(qualified)) |fd| { + const plain_method_fd = self.program_index.fn_ast_map.get(qualified); + if (plain_method_fd) |fd| { + if (self.checkCallArity(fd, qualified, method_args.items.len, true, c.callee.span)) return Ref.none; if (!self.lowered_functions.contains(qualified)) { self.lazyLowerFunction(qualified); } - _ = fd; } if (self.resolveFuncByName(qualified)) |fid| { const func = &self.module.functions.items[@intFromEnum(fid)]; @@ -985,6 +997,7 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { const params = func.params; const has_ctx = func.has_implicit_ctx; self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty); + if (plain_method_fd) |fd| self.appendDefaultArgs(fd, &method_args); // Note: coerceCallArgs can trigger protocol thunk creation // (module.addFunction), invalidating func pointer. // Use pre-extracted params/ret_ty (+ has_ctx) instead of @@ -1073,6 +1086,10 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { return self.emitError(eff_field, c.callee.span); } } + const ufcs_arity_fd: ?*const ast.FnDecl = if (sel_author) |sf| sf.decl else ufcs_fd; + if (ufcs_arity_fd) |fd| { + if (self.checkCallArity(fd, fa.field, method_args.items.len, true, c.callee.span)) return Ref.none; + } const ufcs_fid: ?FuncId = blk_uf: { if (sel_author) |sf| { break :blk_uf self.selectedFuncId(sf, eff_field); @@ -1092,6 +1109,7 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { // free function's first param is `*T` and the receiver is a // value `T`, pass its address instead of a by-value copy self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty); + if (ufcs_arity_fd) |fd| self.appendDefaultArgs(fd, &method_args); const final_args = self.prependCtxIfNeeded(func, method_args.items); self.coerceCallArgs(final_args, params); return self.builder.call(fid, final_args, ret_ty); @@ -1969,6 +1987,50 @@ pub fn appendDefaultArgs(self: *Lowering, fd: *const ast.FnDecl, args: *std.Arra } } +/// Reject a direct call whose argument count cannot bind to the callee's +/// declared parameter list. `supplied` counts the args as they bind to +/// params — receiver included for dot-dispatch, defaults not yet +/// appended. Returns true when a diagnostic was emitted (the call must +/// not lower). Pack / comptime / generic / `#compiler` / `#builtin` +/// callees bind args through their own dispatch and are exempt. +pub fn checkCallArity(self: *Lowering, fd: *const ast.FnDecl, callee_name: []const u8, supplied: usize, has_receiver: bool, span: ast.Span) bool { + + if (fd.type_params.len > 0 or hasComptimeParams(fd) or isPackFn(fd)) return false; + switch (fd.body.data) { + .compiler_expr, .builtin_expr => return false, + else => {}, + } + var min: usize = 0; + var max: ?usize = fd.params.len; + for (fd.params, 0..) |p, i| { + if (p.is_variadic) { + max = null; + break; + } + if (p.default_expr == null) min = i + 1; + } + if (supplied >= min and (max == null or supplied <= max.?)) return false; + if (self.diagnostics) |d| { + // Dot-dispatch report counts the user-visible args: the receiver + // slot is implicit at the call site, so it is elided from both + // the expected and the supplied counts. + const recv: usize = @intFromBool(has_receiver); + const got = supplied -| recv; + const lo = min -| recv; + const got_verb: []const u8 = if (got == 1) "was" else "were"; + if (max == null) { + const s: []const u8 = if (lo == 1) "" else "s"; + d.addFmt(.err, span, "'{s}' expects at least {d} argument{s}, but {d} {s} given", .{ callee_name, lo, s, got, got_verb }); + } else if (max.? -| recv == lo) { + const s: []const u8 = if (lo == 1) "" else "s"; + d.addFmt(.err, span, "'{s}' expects {d} argument{s}, but {d} {s} given", .{ callee_name, lo, s, got, got_verb }); + } else { + d.addFmt(.err, span, "'{s}' expects between {d} and {d} arguments, but {d} {s} given", .{ callee_name, lo, max.? -| recv, got, got_verb }); + } + } + return true; +} + /// When a bare-identifier call omits trailing positional args and the /// callee's signature provides defaults for them, return a fresh Call /// node with the defaults filled in. Returns null when no expansion is diff --git a/src/ir/lower/stmt.zig b/src/ir/lower/stmt.zig index 4a7b499..b55b669 100644 --- a/src/ir/lower/stmt.zig +++ b/src/ir/lower/stmt.zig @@ -179,7 +179,10 @@ pub fn lowerStmt(self: *Lowering, node: *const Node) void { switch (node.data) { .var_decl => |vd| self.lowerVarDecl(&vd), .const_decl => |cd| self.lowerConstDecl(&cd), - .fn_decl => |fd| self.lowerLocalFnDecl(&fd), + // Pointer capture, not by-value: `lowerLocalFnDecl` registers the + // decl pointer in `fn_ast_map`, so it must point into the AST node, + // not at a stack temporary that the next statement reuses. + .fn_decl => |*fd| self.lowerLocalFnDecl(fd), .return_stmt => |rs| self.lowerReturn(&rs), .raise_stmt => |rs| self.lowerRaise(&rs, node.span), .assignment => |asgn| self.lowerAssignment(&asgn),