From d67fb7b9b36f4393dc4863f165facd47e88f343f Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 1 Jun 2026 12:22:23 +0300 Subject: [PATCH] =?UTF-8?q?ERR/E4.1:=20trace.print=5Finterpreter=5Fframes(?= =?UTF-8?q?)=20=E2=80=94=20Phase=20E4=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last E4 item: a comptime call-frame dump. - New nullary `interp_print_frames` IR op (inst/print). The interpreter maintains a `call_chain` side-stack (push/pop a FuncId around each sx-bodied `call`, freed in deinit) and `printInterpFrames` appends the chain to its output — most-recent-last, with the dump frame itself skipped. emit_llvm makes the op a no-op: compiled code has no interpreter stack, and the only caller is `process.exit`'s dead `is_comptime()` branch. - Lowered from a name-recognized `__interp_print_frames()` builtin (tryLowerReflectionCall + inferExprType → void). - `trace.print_interpreter_frames()` wraps the builtin; wired into `process.exit`'s comptime branch (process.sx now imports trace.sx). - Frame source locations await IR-offset resolution (the comptime analog of DWARF), so only function names print today. examples/252-interp-frames.sx (top-level `#run` drives the dump; exit 0). Phase E4 (entry-point + stdlib error story) is now 100% complete. --- examples/252-interp-frames.sx | 23 ++++++++++++++++++++ library/modules/process.sx | 2 ++ library/modules/trace.sx | 9 ++++++++ src/ir/emit_llvm.zig | 5 +++++ src/ir/inst.zig | 5 +++++ src/ir/interp.zig | 31 +++++++++++++++++++++++++++ src/ir/lower.zig | 6 ++++++ src/ir/print.zig | 1 + tests/expected/252-interp-frames.exit | 1 + tests/expected/252-interp-frames.txt | 5 +++++ 10 files changed, 88 insertions(+) create mode 100644 examples/252-interp-frames.sx create mode 100644 tests/expected/252-interp-frames.exit create mode 100644 tests/expected/252-interp-frames.txt diff --git a/examples/252-interp-frames.sx b/examples/252-interp-frames.sx new file mode 100644 index 0000000..1500779 --- /dev/null +++ b/examples/252-interp-frames.sx @@ -0,0 +1,23 @@ +// `trace.print_interpreter_frames()` (ERR step E4.1). At comptime (`#run`) it +// dumps the interpreter's active sx call-frame chain (most recent call last) to +// the build output; in compiled code it folds to nothing (no interpreter stack +// — the only real caller is `process.exit`'s dead `is_comptime()` branch). The +// dump frame itself is omitted; frame source locations await IR-offset +// resolution, so only names print today. Expected exit: 0. + +#import "modules/std.sx"; +trace :: #import "modules/trace.sx"; + +probe :: () { + trace.print_interpreter_frames(); // dumps the chain: __run_0 → inner → probe +} + +inner :: () { + probe(); +} + +#run inner(); // top-level #run drives the chain + +main :: () -> s32 { + return 0; +} diff --git a/library/modules/process.sx b/library/modules/process.sx index 918972f..f07cb56 100644 --- a/library/modules/process.sx +++ b/library/modules/process.sx @@ -1,4 +1,5 @@ #import "std.sx"; +trace :: #import "trace.sx"; // ===================================================================== // process.sx — subprocess + environment stdlib (POSIX backend). @@ -136,6 +137,7 @@ clib_exit :: (code: s32) -> noreturn #foreign libc "_exit"; exit :: (code: u8, loc: Source_Location = #caller_location) -> noreturn { if is_comptime() { print("\nprocess.exit({}) called from {} at {}:{}\n", code, loc.func, loc.file, loc.line); + trace.print_interpreter_frames(); } clib_exit(xx code); } diff --git a/library/modules/trace.sx b/library/modules/trace.sx index ba4cf25..e05308b 100644 --- a/library/modules/trace.sx +++ b/library/modules/trace.sx @@ -60,3 +60,12 @@ print_current :: () { write(2, s.ptr, xx s.len); } } + +// Dump the comptime (`#run`) interpreter call-frame chain (ERR E4.1). At +// comptime the interpreter walks its active sx frames and appends them to the +// build output; in compiled code this folds to nothing (there is no +// interpreter stack — the only caller is a dead `is_comptime()` branch). +// Frame source locations await IR-offset resolution, so only names print today. +print_interpreter_frames :: () { + __interp_print_frames(); +} diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index 03cdd0a..652b223 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -1663,6 +1663,11 @@ pub const LLVMEmitter = struct { // `false`. A `if is_comptime() { … }` branch becomes dead. self.mapRef(c.LLVMConstInt(self.cached_i1, 0, 0)); }, + .interp_print_frames => { + // No interpreter stack in compiled code; this only ever sits in + // a dead `is_comptime()` branch. Emit nothing. + self.advanceRefCounter(); + }, .const_string => |str_id| { const str = self.ir_mod.types.getString(str_id); const llvm_val = self.emitStringConstant(str); diff --git a/src/ir/inst.zig b/src/ir/inst.zig index a72a4d2..66d2ef7 100644 --- a/src/ir/inst.zig +++ b/src/ir/inst.zig @@ -91,6 +91,11 @@ pub const Op = union(enum) { /// `false`. Lets stdlib (`process.exit`, `assert`) take a comptime-only /// diagnostic branch that dead-codes out of compiled binaries. is_comptime, + /// ERR E4.1 — `trace.print_interpreter_frames()`. At comptime the interp + /// walks its sx call-frame chain and appends it to the output; in compiled + /// code it's a no-op (only ever reached from a dead `is_comptime()` branch, + /// where there is no interpreter stack to walk). + interp_print_frames, /// Comptime-only Type value. Carried as a `Value.type_tag(TypeId)` /// in the interpreter. NEVER emitted to LLVM — types are erased /// after lowering. `emit_llvm` bails loudly if it sees one, diff --git a/src/ir/interp.zig b/src/ir/interp.zig index b926265..82452c4 100644 --- a/src/ir/interp.zig +++ b/src/ir/interp.zig @@ -143,6 +143,10 @@ pub const Interpreter = struct { output: std.ArrayList(u8), call_depth: u32 = 0, max_call_depth: u32 = 256, + /// Active sx call-frame chain (oldest→newest), maintained across `call` for + /// `trace.print_interpreter_frames()` (ERR E4.1). Only sx-bodied frames are + /// tracked — foreign calls return before the frame is pushed. + call_chain: std.ArrayList(FuncId) = .empty, // Heap: dynamically allocated memory blocks heap: std.ArrayList([]u8), @@ -206,6 +210,7 @@ pub const Interpreter = struct { } self.heap.deinit(self.alloc); self.output.deinit(self.alloc); + self.call_chain.deinit(self.alloc); self.global_values.deinit(); self.hooks.deinit(); } @@ -424,6 +429,24 @@ pub const Interpreter = struct { }; } + /// Append the current sx call-frame chain to the interp output, most-recent + /// last (ERR E4.1). The topmost frame is `print_interpreter_frames` itself + /// (the dump site), so it's skipped. Frame source locations await IR-offset + /// resolution (the comptime analog of DWARF), so only function names print. + fn printInterpFrames(self: *Interpreter) void { + const n = self.call_chain.items.len; + if (n <= 1) return; + self.output.appendSlice(self.alloc, "comptime call frames (most recent call last):\n") catch {}; + var i: usize = 0; + while (i < n - 1) : (i += 1) { + const fid = self.call_chain.items[i]; + const fname = self.module.types.getString(self.module.getFunction(fid).name); + const line = std.fmt.allocPrint(self.alloc, " at {s}\n", .{fname}) catch continue; + defer self.alloc.free(line); + self.output.appendSlice(self.alloc, line) catch {}; + } + } + fn callForeign(self: *Interpreter, func: *const inst_mod.Function, args: []const Value) InterpError!Value { const name = self.module.types.getString(func.name); @@ -508,6 +531,10 @@ pub const Interpreter = struct { return self.callForeign(func, args); } + // Track the sx call chain for `trace.print_interpreter_frames()`. + self.call_chain.append(self.alloc, func_id) catch {}; + defer _ = self.call_chain.pop(); + // Compute total refs: params + all instructions across all blocks var total_refs: u32 = @intCast(func.params.len); for (func.blocks.items) |blk| { @@ -609,6 +636,10 @@ pub const Interpreter = struct { .const_null => return .{ .value = .null_val }, .const_undef => return .{ .value = .undef }, .is_comptime => return .{ .value = .{ .boolean = true } }, + .interp_print_frames => { + self.printInterpFrames(); + return .{ .value = .void_val }; + }, .const_type => |tid| return .{ .value = .{ .type_tag = tid } }, // ── Arithmetic ────────────────────────────────────── diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 8f90012..c33e3d7 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -10327,6 +10327,11 @@ pub const Lowering = struct { // serves both). Lets stdlib gate a comptime-only diagnostic branch. return self.builder.emit(.{ .is_comptime = {} }, .bool); } + if (std.mem.eql(u8, name, "__interp_print_frames")) { + // Backs `trace.print_interpreter_frames()`: dumps the interp call + // chain at comptime, no-op in compiled code (ERR E4.1). + return self.builder.emit(.{ .interp_print_frames = {} }, .void); + } if (std.mem.eql(u8, name, "error_tag_name")) { // error_tag_name(e) → look the error-set value's runtime tag id up // in the always-linked tag-name table. The value IS its u32 tag id. @@ -13962,6 +13967,7 @@ pub const Lowering = struct { if (std.mem.eql(u8, bare_name, "field_name")) return .string; if (std.mem.eql(u8, bare_name, "error_tag_name")) return .string; if (std.mem.eql(u8, bare_name, "is_comptime")) return .bool; + if (std.mem.eql(u8, bare_name, "__interp_print_frames")) return .void; if (std.mem.eql(u8, bare_name, "is_flags")) return .bool; if (std.mem.eql(u8, bare_name, "type_of")) return .any; if (std.mem.eql(u8, bare_name, "field_value")) return .any; diff --git a/src/ir/print.zig b/src/ir/print.zig index eb858e3..fb810bc 100644 --- a/src/ir/print.zig +++ b/src/ir/print.zig @@ -144,6 +144,7 @@ fn printInst(instruction: *const Inst, ref_idx: u32, tt: *const TypeTable, write .const_null => try writer.writeAll("const null : "), .const_undef => try writer.writeAll("const undef : "), .is_comptime => try writer.writeAll("is_comptime : "), + .interp_print_frames => try writer.writeAll("interp_print_frames : "), .const_type => |tid| try writer.print("const type({s}) : ", .{tt.typeName(tid)}), // ── Arithmetic ────────────────────────────────────────── diff --git a/tests/expected/252-interp-frames.exit b/tests/expected/252-interp-frames.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/252-interp-frames.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/252-interp-frames.txt b/tests/expected/252-interp-frames.txt new file mode 100644 index 0000000..5b8e62b --- /dev/null +++ b/tests/expected/252-interp-frames.txt @@ -0,0 +1,5 @@ +comptime call frames (most recent call last): + at __run_0 + at inner + at probe +--- build done ---