fix(0123): wrong arg counts to fixed-arity fns error at the call site

checkCallArity compares the supplied count against the declared params
(min = params without trailing defaults, max = params.len, unbounded
past a variadic) at the five plain dispatch sites in lowerCall — bare
selected-author + lazy, namespace alias-gate + qualified, struct
method, ufcs. Pack / comptime / generic / #compiler / #builtin callees
keep their own dispatch. The method/ufcs sites also gain the
appendDefaultArgs fill the generic-instance leg already had, so
trailing defaults work on dot-calls instead of emitting under-arity
calls. lowerStmt's local fn_decl arm now registers a pointer into the
AST node in fn_ast_map, not a stack temporary that aliased every later
local fn.
This commit is contained in:
agra
2026-06-12 01:42:59 +03:00
parent 7d1e23ecc6
commit 7f2b8b5cde
12 changed files with 190 additions and 3 deletions

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,7 @@
10
15
15
6
0
6
true

View File

@@ -0,0 +1 @@
1

View File

@@ -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
| ^^^^^^

View File

@@ -0,0 +1 @@

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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),