ERR/E4.1b: #caller_location + Source_Location (+ namespaced default fix, comptime flush)

Finishes Phase E4. `process.exit` / `assert` now report the caller's location.

#caller_location + Source_Location:
- new `hash_caller_location` token (lexer) + a leaf `caller_location` AST node
  (parser primary-expr; sema + lsp arms).
- `Source_Location :: struct { file; line; col; func }` in std.sx.
- expandCallDefaults rewrites a `#caller_location` param default to a marker
  carrying the CALL site's span + source_file.
- lowerCallerLocation synthesizes the struct: file + line:col via
  errors.SourceLoc.compute over the diagnostics file→source map, stamped with
  the enclosing (caller) function name. inferExprType resolves it to
  Source_Location. Explicitly forwarding a Source_Location through an inner
  call preserves the outermost site.

namespaced default-param expansion (pre-existing crash): expandCallDefaults
bailed on field_access callees, so `mod.fn(args)` with an omitted defaulted
param passed too few args → LLVM "incorrect number of arguments". Now resolves
the namespace fd (by field / qualified name); method-on-value calls (where
`self` shifts the count) stay excluded. Prerequisite for process.exit/assert
(always called namespaced) taking `loc = #caller_location`.

comptime flush: interp.callForeign flushes the interpreter's buffered print
output before invoking any host symbol, so a comptime diagnostic emitted just
before a terminating `_exit` (process.exit at comptime) survives.

process.exit/assert take `loc: Source_Location = #caller_location`; assert
prints `ASSERTION FAILED at <file>:<line>: <msg>`. examples 250 (assert
file:line), 251 (caller-location + forwarding). The two ffi-objc *.ir
snapshots are regenerated — adding Source_Location to std.sx renumbers the
global string pool the type/field-name tables index (benign, identical IR).
This commit is contained in:
agra
2026-06-01 12:00:03 +03:00
parent 6f77c55613
commit e04bec488b
17 changed files with 1212 additions and 968 deletions

View File

@@ -62,6 +62,11 @@ pub const Node = struct {
try_expr: TryExpr,
catch_expr: CatchExpr,
onfail_stmt: OnFailStmt,
/// `#caller_location` — a marker that, as a parameter default, resolves
/// to a `Source_Location` of the call site (ERR E4.1b). The node's
/// `span`/`source_file` carry the location (rewritten to the call site
/// during default expansion). No payload.
caller_location: void,
pack_index_type_expr: PackIndexTypeExpr,
comptime_pack_ref: ComptimePackRef,
force_unwrap: ForceUnwrap,

View File

@@ -426,6 +426,15 @@ pub const Interpreter = struct {
fn callForeign(self: *Interpreter, func: *const inst_mod.Function, args: []const Value) InterpError!Value {
const name = self.module.types.getString(func.name);
// A foreign call may not return (e.g. `process.exit` → `_exit`), which
// would discard the interpreter's buffered `print` output (otherwise
// flushed only after `#run` completes). Flush it first so comptime
// diagnostics emitted just before a terminating call survive.
if (self.output.items.len > 0) {
_ = std.c.write(1, self.output.items.ptr, self.output.items.len);
self.output.clearRetainingCapacity();
}
const symbol = (host_ffi.lookupSymbol(self.alloc, name) catch return bailDetail("comptime foreign call: dlsym error looking up symbol")) orelse {
if (last_bail_detail == null) last_bail_detail = "comptime foreign call: symbol not found via dlsym (target-specific binding called at compile time?)";
return error.CannotEvalComptime;

View File

@@ -2657,6 +2657,7 @@ pub const Lowering = struct {
.try_expr => |te| self.lowerTry(te.operand, node.span),
.catch_expr => |ce| self.lowerCatch(&ce, node.span),
.caller_location => self.lowerCallerLocation(node),
else => self.emitError("unknown_expr", node.span),
};
}
@@ -11096,16 +11097,40 @@ pub const Lowering = struct {
/// node with the defaults filled in. Returns null when no expansion is
/// needed (callee unknown, all args provided, or no defaults available).
fn expandCallDefaults(self: *Lowering, c: *const ast.Call) ?*ast.Call {
if (c.callee.data != .identifier) return null;
const id_name = c.callee.data.identifier.name;
const eff_name = blk: {
const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name;
if (self.ufcs_alias_map.get(id_name)) |target| {
break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
const fd = blk: {
switch (c.callee.data) {
.identifier => |id| {
const eff_name = blk2: {
const scoped = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
if (self.ufcs_alias_map.get(id.name)) |target| {
break :blk2 if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
}
break :blk2 scoped;
};
break :blk self.fn_ast_map.get(eff_name) orelse return null;
},
// Namespace call `mod.fn(args)` — args map directly to params
// (no `self` prepend), so default expansion is the same shape as
// a bare call. A METHOD call `value.method(args)` prepends `self`
// (arg/param counts are offset), so it's excluded: only treat the
// receiver as a namespace when it isn't a value in scope.
.field_access => |fa| {
const obj_name: ?[]const u8 = switch (fa.object.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
else => null,
};
const name = obj_name orelse return null;
if (self.scope) |scope| {
if (scope.lookup(name) != null) return null; // method call on a value
}
if (self.global_names.contains(name)) return null;
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ name, fa.field }) catch fa.field;
break :blk self.fn_ast_map.get(qualified) orelse self.fn_ast_map.get(fa.field) orelse return null;
},
else => return null,
}
break :blk scoped;
};
const fd = self.fn_ast_map.get(eff_name) orelse return null;
if (c.args.len >= fd.params.len) return null;
var end: usize = c.args.len;
while (end < fd.params.len) : (end += 1) {
@@ -11117,7 +11142,17 @@ pub const Lowering = struct {
for (c.args, 0..) |arg, i| new_args[i] = arg;
var i: usize = c.args.len;
while (i < end) : (i += 1) {
new_args[i] = fd.params[i].default_expr.?;
const def = fd.params[i].default_expr.?;
// `#caller_location` resolves at the CALL site, not the callee's
// signature: emit a fresh marker carrying the call's span + file so
// lowering synthesizes the caller's `Source_Location` (ERR E4.1b).
if (def.data == .caller_location) {
const n = self.alloc.create(ast.Node) catch return null;
n.* = .{ .span = c.callee.span, .data = .{ .caller_location = {} }, .source_file = c.callee.source_file };
new_args[i] = n;
} else {
new_args[i] = def;
}
}
const new_call = self.alloc.create(ast.Call) catch return null;
new_call.* = .{ .callee = c.callee, .args = new_args };
@@ -13863,6 +13898,7 @@ pub const Lowering = struct {
if (op_ty == channel) break :blk .void;
break :blk self.failableSuccessType(op_ty);
},
.caller_location => self.module.types.findByName(self.module.types.internString("Source_Location")) orelse .unresolved,
.if_expr => |ie| {
// If-else types as its branches' unified type. A `noreturn`
// branch (one that diverges — `return` / `raise` / `break` /
@@ -15574,6 +15610,46 @@ pub const Lowering = struct {
/// callees, propagation from a value-carrying caller, and `try` inside an
/// `or` chain need the error-channel tuple ABI / fallback routing — those
/// land in E1.4b/E2, so we bail loudly here.
/// Synthesize a `Source_Location` value for a `#caller_location` marker
/// (ERR E4.1b). The node's `span`/`source_file` are the CALL site (rewritten
/// by `expandCallDefaults`); resolve them to file / line:col against the
/// source text and stamp the enclosing (caller) function name.
fn lowerCallerLocation(self: *Lowering, node: *const Node) Ref {
const sl_tid = self.module.types.findByName(self.module.types.internString("Source_Location")) orelse {
if (self.diagnostics) |d| d.addFmt(.err, node.span, "`#caller_location` needs `Source_Location` (from std.sx) in scope", .{});
return self.builder.constInt(0, .void);
};
const file = node.source_file orelse self.current_source_file orelse (self.main_file orelse "");
const src = self.sourceForFile(file);
const loc = errors.SourceLoc.compute(src, node.span.start);
const func_name = self.currentFunctionName();
var fields = [_]Ref{
self.builder.constString(self.module.types.internString(file)),
self.builder.constInt(@intCast(loc.line), .s32),
self.builder.constInt(@intCast(loc.col), .s32),
self.builder.constString(self.module.types.internString(func_name)),
};
return self.builder.emit(.{ .struct_init = .{ .fields = self.alloc.dupe(Ref, &fields) catch unreachable } }, sl_tid);
}
/// The source text for `file`, via the diagnostics' file→source map (which
/// includes the main file). Empty if unavailable — line:col then degrade to
/// 1:1 rather than crash.
fn sourceForFile(self: *Lowering, file: []const u8) []const u8 {
const diags = self.diagnostics orelse return "";
if (diags.import_sources) |is| {
if (is.get(file)) |s| return s;
}
return diags.source;
}
/// Name of the function currently being lowered (the caller, at a
/// `#caller_location` site), or "" outside any function.
fn currentFunctionName(self: *Lowering) []const u8 {
const fid = self.builder.func orelse return "";
return self.module.types.getString(self.module.functions.items[@intFromEnum(fid)].name);
}
fn lowerTry(self: *Lowering, operand: *const Node, span: ast.Span) Ref {
// (1) `try` is legal only inside a failable function.
const caller_ret = self.effectiveReturnType() orelse {

View File

@@ -96,6 +96,7 @@ pub const Lexer = struct {
.{ "#jni_main", Tag.hash_jni_main },
.{ "#selector", Tag.hash_selector },
.{ "#property", Tag.hash_property },
.{ "#caller_location", Tag.hash_caller_location },
};
inline for (directives) |d| {
const keyword = d[0];

View File

@@ -1709,6 +1709,7 @@ pub const Server = struct {
.hash_jni_main,
.hash_selector,
.hash_property,
.hash_caller_location,
=> ST.keyword,
.kw_f32, .kw_f64, .kw_Type, .kw_Self => ST.type_,

View File

@@ -2773,6 +2773,10 @@ pub const Parser = struct {
const inner = try self.parseExpr();
return try self.createNode(start, .{ .comptime_expr = .{ .expr = inner } });
},
.hash_caller_location => {
self.advance();
return try self.createNode(start, .{ .caller_location = {} });
},
.hash_objc_call, .hash_jni_call, .hash_jni_static_call => {
return try self.parseFfiIntrinsicCall();
},

View File

@@ -1136,6 +1136,7 @@ pub const Analyzer = struct {
.try_expr => |te| {
try self.analyzeNode(te.operand);
},
.caller_location => {}, // leaf marker (ERR E4.1b) — no sub-nodes
.catch_expr => |ce| {
try self.analyzeNode(ce.operand);
try self.pushScope();
@@ -1587,6 +1588,7 @@ pub fn findNodeAtOffset(node: *Node, offset: u32) ?*Node {
if (findNodeAtOffset(se.operand, offset)) |found| return found;
},
.break_expr, .continue_expr => {},
.caller_location => {},
.assignment => |asgn| {
if (findNodeAtOffset(asgn.target, offset)) |found| return found;
if (findNodeAtOffset(asgn.value, offset)) |found| return found;

View File

@@ -132,6 +132,7 @@ pub const Tag = enum {
hash_jni_method_descriptor, // `#jni_method_descriptor("(Sig)Ret")` per-method JNI descriptor override
hash_selector, // `#selector("explicit:string")` per-method Obj-C selector override (Phase 3.2)
hash_property, // `#property[(modifier, ...)]` field directive — synthesizes getter/setter dispatch (M2.2)
hash_caller_location, // `#caller_location` — as a param default, synthesizes the call site's Source_Location (ERR E4.1b)
hash_jni_env, // `#jni_env(env) { body }` block-form env-scoping intrinsic
hash_jni_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity
triple_minus, // ---