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

@@ -1,7 +1,7 @@
// `assert` (ERR step E4.1): a false condition prints `ASSERTION FAILED: <msg>` // `assert` (ERR steps E4.1 / E4.1b): a false condition prints `ASSERTION
// and exits 1; a true condition is a no-op. Built on `process.exit`. (The // FAILED at <file>:<line>: <msg>` (the caller's location, via the
// caller's `file:line` in the message rides on `#caller_location` — E4.1b.) // `#caller_location` default param) and exits 1; a true condition is a no-op.
// Expected exit code: 1. // Built on `process.exit`. Expected exit code: 1.
#import "modules/std.sx"; #import "modules/std.sx";
proc :: #import "modules/process.sx"; proc :: #import "modules/process.sx";

View File

@@ -0,0 +1,22 @@
// `#caller_location` (ERR step E4.1b). As a parameter's default value it
// resolves to a `Source_Location` of the CALL site — file, line:col, and the
// enclosing function — rather than the callee's signature. Explicitly
// forwarding a `Source_Location` through an inner call preserves the outermost
// site (so a logging wrapper reports where IT was called). Expected exit: 0.
#import "modules/std.sx";
note :: (loc: Source_Location = #caller_location) {
print("note from {} (line {})\n", loc.func, loc.line);
}
// Forwards its own caller location through to `note`.
wrap :: (loc: Source_Location = #caller_location) {
note(loc);
}
main :: () -> s32 {
note(); // call site → func main
wrap(); // forwarded → still reports this line in main
return 0;
}

View File

@@ -130,25 +130,22 @@ clib_exit :: (code: s32) -> noreturn #foreign libc "_exit";
// Stop the process immediately with exit code `code`. Does NOT unwind: // Stop the process immediately with exit code `code`. Does NOT unwind:
// no `defer` / `onfail` cleanup runs, no error-trace frames are pushed — // no `defer` / `onfail` cleanup runs, no error-trace frames are pushed —
// it's the POSIX `_exit(2)` syscall. At comptime (`#run`) it terminates the // it's the POSIX `_exit(2)` syscall. At comptime (`#run`) it terminates the
// COMPILER with the same code after printing a short diagnostic; in compiled // COMPILER with the same code after printing a diagnostic naming the call site
// code the `is_comptime()` branch folds away to just the syscall. // (`loc` defaults to `#caller_location`); in compiled code the `is_comptime()`
// // branch folds away to just the syscall.
// (PLAN-ERR E4.1 also specifies a `loc: Source_Location = #caller_location` exit :: (code: u8, loc: Source_Location = #caller_location) -> noreturn {
// parameter and an interpreter-frame dump in the comptime branch. Both ride
// on the `#caller_location` directive — deferred to E4.1b.)
exit :: (code: u8) -> noreturn {
if is_comptime() { if is_comptime() {
print("\nprocess.exit({}) called at comptime\n", code); print("\nprocess.exit({}) called from {} at {}:{}\n", code, loc.func, loc.file, loc.line);
} }
clib_exit(xx code); clib_exit(xx code);
} }
// Abort with a message when `cond` is false. Prints `ASSERTION FAILED: <msg>` // Abort with a message when `cond` is false. Prints `ASSERTION FAILED at
// then exits 1; a true condition is a no-op. (E4.1b adds the caller's // <file>:<line>: <msg>` (the caller's location, via `#caller_location`) then
// `file:line` via `#caller_location`.) // exits 1; a true condition is a no-op.
assert :: (cond: bool, msg: string) { assert :: (cond: bool, msg: string, loc: Source_Location = #caller_location) {
if !cond { if !cond {
print("ASSERTION FAILED: {}\n", msg); print("ASSERTION FAILED at {}:{}: {}\n", loc.file, loc.line, msg);
exit(1); exit(1);
} }
} }

View File

@@ -23,6 +23,16 @@ is_flags :: ($T: Type) -> bool #builtin;
field_value_int :: ($T: Type, idx: s64) -> s64 #builtin; field_value_int :: ($T: Type, idx: s64) -> s64 #builtin;
field_index :: ($T: Type, val: T) -> s64 #builtin; field_index :: ($T: Type, val: T) -> s64 #builtin;
error_tag_name :: (e: $T) -> string #builtin; error_tag_name :: (e: $T) -> string #builtin;
// Call-site location, synthesized by the `#caller_location` directive when it
// is a parameter's default value (ERR E4.1b). `process.exit` / `assert` use it
// to report where they were invoked.
Source_Location :: struct {
file: string;
line: s32;
col: s32;
func: string;
}
string :: []u8 #builtin; string :: []u8 #builtin;
#import "allocators.sx"; #import "allocators.sx";

View File

@@ -62,6 +62,11 @@ pub const Node = struct {
try_expr: TryExpr, try_expr: TryExpr,
catch_expr: CatchExpr, catch_expr: CatchExpr,
onfail_stmt: OnFailStmt, 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, pack_index_type_expr: PackIndexTypeExpr,
comptime_pack_ref: ComptimePackRef, comptime_pack_ref: ComptimePackRef,
force_unwrap: ForceUnwrap, 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 { fn callForeign(self: *Interpreter, func: *const inst_mod.Function, args: []const Value) InterpError!Value {
const name = self.module.types.getString(func.name); 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 { 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?)"; 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; return error.CannotEvalComptime;

View File

@@ -2657,6 +2657,7 @@ pub const Lowering = struct {
.try_expr => |te| self.lowerTry(te.operand, node.span), .try_expr => |te| self.lowerTry(te.operand, node.span),
.catch_expr => |ce| self.lowerCatch(&ce, node.span), .catch_expr => |ce| self.lowerCatch(&ce, node.span),
.caller_location => self.lowerCallerLocation(node),
else => self.emitError("unknown_expr", node.span), 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 /// node with the defaults filled in. Returns null when no expansion is
/// needed (callee unknown, all args provided, or no defaults available). /// needed (callee unknown, all args provided, or no defaults available).
fn expandCallDefaults(self: *Lowering, c: *const ast.Call) ?*ast.Call { fn expandCallDefaults(self: *Lowering, c: *const ast.Call) ?*ast.Call {
if (c.callee.data != .identifier) return null; const fd = blk: {
const id_name = c.callee.data.identifier.name; switch (c.callee.data) {
const eff_name = blk: { .identifier => |id| {
const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name; const eff_name = blk2: {
if (self.ufcs_alias_map.get(id_name)) |target| { const scoped = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target; 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; if (c.args.len >= fd.params.len) return null;
var end: usize = c.args.len; var end: usize = c.args.len;
while (end < fd.params.len) : (end += 1) { 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; for (c.args, 0..) |arg, i| new_args[i] = arg;
var i: usize = c.args.len; var i: usize = c.args.len;
while (i < end) : (i += 1) { 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; const new_call = self.alloc.create(ast.Call) catch return null;
new_call.* = .{ .callee = c.callee, .args = new_args }; new_call.* = .{ .callee = c.callee, .args = new_args };
@@ -13863,6 +13898,7 @@ pub const Lowering = struct {
if (op_ty == channel) break :blk .void; if (op_ty == channel) break :blk .void;
break :blk self.failableSuccessType(op_ty); break :blk self.failableSuccessType(op_ty);
}, },
.caller_location => self.module.types.findByName(self.module.types.internString("Source_Location")) orelse .unresolved,
.if_expr => |ie| { .if_expr => |ie| {
// If-else types as its branches' unified type. A `noreturn` // If-else types as its branches' unified type. A `noreturn`
// branch (one that diverges — `return` / `raise` / `break` / // 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 /// callees, propagation from a value-carrying caller, and `try` inside an
/// `or` chain need the error-channel tuple ABI / fallback routing — those /// `or` chain need the error-channel tuple ABI / fallback routing — those
/// land in E1.4b/E2, so we bail loudly here. /// 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 { fn lowerTry(self: *Lowering, operand: *const Node, span: ast.Span) Ref {
// (1) `try` is legal only inside a failable function. // (1) `try` is legal only inside a failable function.
const caller_ret = self.effectiveReturnType() orelse { const caller_ret = self.effectiveReturnType() orelse {

View File

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

View File

@@ -1709,6 +1709,7 @@ pub const Server = struct {
.hash_jni_main, .hash_jni_main,
.hash_selector, .hash_selector,
.hash_property, .hash_property,
.hash_caller_location,
=> ST.keyword, => ST.keyword,
.kw_f32, .kw_f64, .kw_Type, .kw_Self => ST.type_, .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(); const inner = try self.parseExpr();
return try self.createNode(start, .{ .comptime_expr = .{ .expr = inner } }); 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 => { .hash_objc_call, .hash_jni_call, .hash_jni_static_call => {
return try self.parseFfiIntrinsicCall(); return try self.parseFfiIntrinsicCall();
}, },

View File

@@ -1136,6 +1136,7 @@ pub const Analyzer = struct {
.try_expr => |te| { .try_expr => |te| {
try self.analyzeNode(te.operand); try self.analyzeNode(te.operand);
}, },
.caller_location => {}, // leaf marker (ERR E4.1b) — no sub-nodes
.catch_expr => |ce| { .catch_expr => |ce| {
try self.analyzeNode(ce.operand); try self.analyzeNode(ce.operand);
try self.pushScope(); try self.pushScope();
@@ -1587,6 +1588,7 @@ pub fn findNodeAtOffset(node: *Node, offset: u32) ?*Node {
if (findNodeAtOffset(se.operand, offset)) |found| return found; if (findNodeAtOffset(se.operand, offset)) |found| return found;
}, },
.break_expr, .continue_expr => {}, .break_expr, .continue_expr => {},
.caller_location => {},
.assignment => |asgn| { .assignment => |asgn| {
if (findNodeAtOffset(asgn.target, offset)) |found| return found; if (findNodeAtOffset(asgn.target, offset)) |found| return found;
if (findNodeAtOffset(asgn.value, 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_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_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_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_env, // `#jni_env(env) { body }` block-form env-scoping intrinsic
hash_jni_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity hash_jni_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity
triple_minus, // --- triple_minus, // ---

View File

@@ -1,2 +1,2 @@
first assert passed first assert passed
ASSERTION FAILED: two plus two is not five ASSERTION FAILED at /Users/agra/projects/sx/examples/250-assert.sx:12: two plus two is not five

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,2 @@
note from main (line 19)
note from main (line 20)

File diff suppressed because it is too large Load Diff

View File

@@ -954,5 +954,3 @@ entry:
store ptr %selN, ptr @OBJC_SELECTOR_REFERENCES_actualSelectorName, align 8 store ptr %selN, ptr @OBJC_SELECTOR_REFERENCES_actualSelectorName, align 8
ret void ret void
} }