diff --git a/examples/248-log-and-comptime.sx b/examples/248-log-and-comptime.sx new file mode 100644 index 0000000..339ac9b --- /dev/null +++ b/examples/248-log-and-comptime.sx @@ -0,0 +1,31 @@ +// `log.sx` leveled logging + the `is_comptime()` builtin (ERR step E4.1, +// slice 1). `log.{warn,info,err,debug}` interpolate like `print` and write +// `LEVEL: ` to stderr. `is_comptime()` is `true` under `#run` (the +// comptime interpreter) and folds to `false` in compiled code, so a gated +// branch dead-codes out of the runtime binary. +// +// (`log.error` is spelled `log.err` — `error` is a reserved keyword. The +// `process.exit` / `assert` part of E4.1 is blocked on `noreturn` codegen, +// issue 0058.) +// +// The test runner merges stderr+stdout; the log lines (stderr) precede the +// single stdout line. Expected exit code: 0. + +#import "modules/std.sx"; +log :: #import "modules/log.sx"; + +probe :: () -> s32 { + if is_comptime() { return 1; } // comptime interpreter path + return 2; // compiled-code path +} + +CT :: #run probe(); // folds to 1 (run in the interpreter) + +main :: () -> s32 { + log.warn("disk {}% full", 91); + log.info("user {} connected", "alice"); + log.err("bad fd {}", 7); + log.debug("trace x={}", 42); + print("[stdout] is_comptime runtime={} comptime={}\n", probe(), CT); + return 0; +} diff --git a/examples/249-process-exit.sx b/examples/249-process-exit.sx new file mode 100644 index 0000000..bf635f3 --- /dev/null +++ b/examples/249-process-exit.sx @@ -0,0 +1,15 @@ +// `process.exit` (ERR step E4.1): immediate process termination with an exit +// code. No `defer` / `onfail` cleanup runs and no error-trace frames are pushed +// — it's POSIX `_exit(2)`. Here a runtime call exits 42; the line after never +// runs. (sx `print` writes unbuffered via `write(2)`, so the "starting" line +// still appears despite `_exit` skipping the stdio flush.) Expected exit: 42. + +#import "modules/std.sx"; +proc :: #import "modules/process.sx"; + +main :: () -> s32 { + print("starting\n"); + proc.exit(42); + print("unreachable\n"); + return 0; +} diff --git a/examples/250-assert.sx b/examples/250-assert.sx new file mode 100644 index 0000000..51acb82 --- /dev/null +++ b/examples/250-assert.sx @@ -0,0 +1,15 @@ +// `assert` (ERR step E4.1): a false condition prints `ASSERTION FAILED: ` +// and exits 1; a true condition is a no-op. Built on `process.exit`. (The +// caller's `file:line` in the message rides on `#caller_location` — E4.1b.) +// Expected exit code: 1. + +#import "modules/std.sx"; +proc :: #import "modules/process.sx"; + +main :: () -> s32 { + proc.assert(2 + 2 == 4, "arithmetic"); // passes → no-op + print("first assert passed\n"); + proc.assert(2 + 2 == 5, "two plus two is not five"); // fails → abort + print("unreachable\n"); + return 0; +} diff --git a/library/modules/log.sx b/library/modules/log.sx new file mode 100644 index 0000000..015d82e --- /dev/null +++ b/library/modules/log.sx @@ -0,0 +1,29 @@ +#import "std.sx"; + +// ===================================================================== +// log.sx — plain leveled logging (ERR step E4.1), orthogonal to the +// error channel. Each entry is written to stderr as `LEVEL: \n`, +// where is the formatted `fmt` + args (same `{}` interpolation as +// `print`). Sink is stderr (fd 2) so log output stays out of a program's +// stdout data stream. +// +// Note: PLAN-ERR §log sketches a `LEVEL ts msg` line with an ISO-8601 +// UTC timestamp. The timestamp is deferred — it needs a clock binding +// and would make golden tests time-dependent; the level + message are +// the load-bearing parts. Add `ts` once a pinnable clock lands. +// ===================================================================== + +libc :: #library "c"; + +write :: (fd: s32, buf: [*]u8, count: usize) -> isize #foreign libc; + +// Prefix the level, append a newline, write the whole line to stderr. +emit :: (level: string, msg: string) { + line := concat(concat(level, msg), "\n"); + write(2, line.ptr, xx line.len); +} + +warn :: ($fmt: string, ..$args) { #insert build_format(fmt); #insert "emit(\"WARN: \", result);"; } +info :: ($fmt: string, ..$args) { #insert build_format(fmt); #insert "emit(\"INFO: \", result);"; } +debug :: ($fmt: string, ..$args) { #insert build_format(fmt); #insert "emit(\"DEBUG: \", result);"; } +err :: ($fmt: string, ..$args) { #insert build_format(fmt); #insert "emit(\"ERROR: \", result);"; } diff --git a/library/modules/process.sx b/library/modules/process.sx index 05fa72f..0d8955d 100644 --- a/library/modules/process.sx +++ b/library/modules/process.sx @@ -116,3 +116,39 @@ find_executable :: (name: [:0]u8) -> ?string { } null; } + +// ── Process termination (ERR step E4.1) ─────────────────────────────── + +// Bound to POSIX `_exit(2)` (immediate termination — no atexit, no stdio +// flush), NOT libc `exit(3)`. Two reasons: (1) it matches `process.exit`'s +// "immediate stop, no cleanup" contract; (2) sx's `print` writes unbuffered +// via `write(2)`, so skipping the stdio flush loses nothing. Binding the +// symbol `"exit"` would also collide with this module's own `exit` function +// at the link level. +clib_exit :: (code: s32) -> noreturn #foreign libc "_exit"; + +// Stop the process immediately with exit code `code`. Does NOT unwind: +// no `defer` / `onfail` cleanup runs, no error-trace frames are pushed — +// 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 +// code the `is_comptime()` branch folds away to just the syscall. +// +// (PLAN-ERR E4.1 also specifies a `loc: Source_Location = #caller_location` +// 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() { + print("\nprocess.exit({}) called at comptime\n", code); + } + clib_exit(xx code); +} + +// Abort with a message when `cond` is false. Prints `ASSERTION FAILED: ` +// then exits 1; a true condition is a no-op. (E4.1b adds the caller's +// `file:line` via `#caller_location`.) +assert :: (cond: bool, msg: string) { + if !cond { + print("ASSERTION FAILED: {}\n", msg); + exit(1); + } +} diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index 16cd4ff..03cdd0a 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -1658,6 +1658,11 @@ pub const LLVMEmitter = struct { const llvm_val = c.LLVMConstInt(self.cached_i1, @intFromBool(val), 0); self.mapRef(llvm_val); }, + .is_comptime => { + // Compiled code is never the comptime interpreter → constant + // `false`. A `if is_comptime() { … }` branch becomes dead. + self.mapRef(c.LLVMConstInt(self.cached_i1, 0, 0)); + }, .const_string => |str_id| { const str = self.ir_mod.types.getString(str_id); const llvm_val = self.emitStringConstant(str); @@ -2343,7 +2348,10 @@ pub const LLVMEmitter = struct { args[j] = self.coerceArg(args[j], param_types[j]); } } - const call_label: [*:0]const u8 = if (instruction.ty == .void or callee_uses_sret) "" else "call"; + // A `void`/`noreturn` call has no value, so it must stay + // unnamed (LLVM rejects a named void result). + const call_is_void_like = instruction.ty == .void or instruction.ty == .noreturn; + const call_label: [*:0]const u8 = if (call_is_void_like or callee_uses_sret) "" else "call"; var result = c.LLVMBuildCall2(self.builder, fn_ty, callee, args.ptr, arg_count, call_label); if (callee_uses_sret) { // Mirror the function-decl `sret()` attribute on the call site so the @@ -2354,7 +2362,7 @@ pub const LLVMEmitter = struct { c.LLVMAddCallSiteAttribute(result, param1_idx, sret_attr); // Load the actual struct value the callee wrote into the slot. result = c.LLVMBuildLoad2(self.builder, callee_raw_ret, sret_slot, "sret.load"); - } else if (instruction.ty != .void and callee_func.is_extern) { + } else if (!call_is_void_like and callee_func.is_extern) { // Coerce ABI return value (e.g. i64 / [2 x i64]) back to IR struct type if needed const expected_ty = self.toLLVMType(instruction.ty); result = self.coerceArg(result, expected_ty); @@ -2455,11 +2463,12 @@ pub const LLVMEmitter = struct { } } const fn_ty = c.LLVMFunctionType(ret_ty, param_tys.ptr, arg_count, 0); - var result = c.LLVMBuildCall2(self.builder, fn_ty, callee, args.ptr, arg_count, if (instruction.ty == .void) "" else "icall"); + const icall_void_like = instruction.ty == .void or instruction.ty == .noreturn; + var result = c.LLVMBuildCall2(self.builder, fn_ty, callee, args.ptr, arg_count, if (icall_void_like) "" else "icall"); // Coerce call result to instruction's expected type const expected_ty = self.toLLVMType(instruction.ty); - if (instruction.ty != .void and c.LLVMTypeOf(result) != expected_ty) { + if (!icall_void_like and c.LLVMTypeOf(result) != expected_ty) { result = self.coerceArg(result, expected_ty); } self.mapRef(result); diff --git a/src/ir/inst.zig b/src/ir/inst.zig index 2e0ab46..a72a4d2 100644 --- a/src/ir/inst.zig +++ b/src/ir/inst.zig @@ -85,6 +85,12 @@ pub const Op = union(enum) { const_string: StringId, const_null, const_undef, // `---` undefined initializer + /// ERR E4.1 — `is_comptime()` builtin. The SAME lowered IR is run by both + /// the comptime interpreter and the compiled backend, so this can't fold at + /// lower time: the interp evaluates it to `true`, emit_llvm emits constant + /// `false`. Lets stdlib (`process.exit`, `assert`) take a comptime-only + /// diagnostic branch that dead-codes out of compiled binaries. + is_comptime, /// 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 39357c5..171a25b 100644 --- a/src/ir/interp.zig +++ b/src/ir/interp.zig @@ -599,6 +599,7 @@ pub const Interpreter = struct { .const_string => |sid| return .{ .value = .{ .string = self.module.types.getString(sid) } }, .const_null => return .{ .value = .null_val }, .const_undef => return .{ .value = .undef }, + .is_comptime => return .{ .value = .{ .boolean = true } }, .const_type => |tid| return .{ .value = .{ .type_tag = tid } }, // ── Arithmetic ────────────────────────────────────── diff --git a/src/ir/lower.zig b/src/ir/lower.zig index a4eb6b4..1648614 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1264,8 +1264,8 @@ pub const Lowering = struct { // Lower the function body (set target_type to return type for implicit returns) const saved_target = self.target_type; - self.target_type = if (ret_ty != .void) ret_ty else null; - if (ret_ty != .void) { + self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null; + if (ret_ty != .void and ret_ty != .noreturn) { const body_val = self.lowerBlockValue(fd.body); if (!self.currentBlockHasTerminator()) { if (body_val) |val| { @@ -1282,6 +1282,8 @@ pub const Lowering = struct { } } } else { + // void / noreturn: no value to return — lower as statements and + // let `ensureTerminator` close the block (ret void / unreachable). self.lowerBlock(fd.body); self.ensureTerminator(ret_ty); } @@ -1420,8 +1422,8 @@ pub const Lowering = struct { // Lower the function body, capturing the last expression's value for implicit return const saved_target = self.target_type; - self.target_type = if (ret_ty != .void) ret_ty else null; - if (ret_ty != .void) { + self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null; + if (ret_ty != .void and ret_ty != .noreturn) { const body_val = self.lowerBlockValue(fd.body); if (!self.currentBlockHasTerminator()) { if (body_val) |val| { @@ -1438,6 +1440,8 @@ pub const Lowering = struct { } } } else { + // void / noreturn: no value to return — lower as statements and + // let `ensureTerminator` close the block (ret void / unreachable). self.lowerBlock(fd.body); self.ensureTerminator(ret_ty); } @@ -10316,6 +10320,12 @@ pub const Lowering = struct { .struct_type = ty, } }, .string); } + if (std.mem.eql(u8, name, "is_comptime")) { + // True under the comptime interpreter, false in compiled code — the + // op decides per backend (it can't fold here, since the same IR + // serves both). Lets stdlib gate a comptime-only diagnostic branch. + return self.builder.emit(.{ .is_comptime = {} }, .bool); + } 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. @@ -13915,6 +13925,7 @@ pub const Lowering = struct { if (std.mem.eql(u8, bare_name, "field_index")) return .s64; 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, "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; @@ -15731,24 +15742,7 @@ pub const Lowering = struct { self.builder.switchToBlock(handle_bb); const body_val = self.runCatchBody(ce, err_val, err_set, succ_ty); if (!self.currentBlockHasTerminator()) { - // A non-diverging handler must produce a value of the success type. - // A value-less (void) body is a type error — diagnose and feed an - // undef placeholder so the merge phi stays well-typed (rather than - // coercing `void` into a bad ref). - const bv: Ref = blk: { - if (body_val) |v| { - const vty = self.builder.getRefType(v); - if (vty != .void) break :blk self.coerceToType(v, vty, succ_ty); - } - if (self.diagnostics) |diags| { - diags.addFmt(.err, span, "`catch` body must produce a value of type '{s}' (or diverge with `return` / `raise`)", .{self.formatTypeName(succ_ty)}); - } - break :blk self.builder.constUndef(succ_ty); - }; - // Absorption clear on a non-diverging handler (see the pure-failable - // path above): the body saw the trace, now it's consumed. - self.emitTraceClear(); - self.builder.br(merge_bb, &.{bv}); + self.finishCatchHandler(body_val, succ_ty, merge_bb, span); } self.builder.switchToBlock(merge_bb); @@ -15802,26 +15796,46 @@ pub const Lowering = struct { const tag = self.builder.blockParam(handle_bb, 0, err_set); const body_val = self.runCatchBody(ce, tag, err_set, if (has_value) succ_ty else null); if (!self.currentBlockHasTerminator()) { - self.emitTraceClear(); - if (has_value) { - const bv: Ref = blk: { - if (body_val) |v| { - const vty = self.builder.getRefType(v); - if (vty != .void) break :blk self.coerceToType(v, vty, succ_ty); - } - if (self.diagnostics) |d| d.addFmt(.err, span, "`catch` body must produce a value of type '{s}' (or diverge with `return` / `raise`)", .{self.formatTypeName(succ_ty)}); - break :blk self.builder.constUndef(succ_ty); - }; - self.builder.br(merge_bb, &.{bv}); - } else { - self.builder.br(merge_bb, &.{}); - } + self.finishCatchHandler(body_val, succ_ty, merge_bb, span); } self.builder.switchToBlock(merge_bb); return if (has_value) self.builder.blockParam(merge_bb, 0, succ_ty) else self.builder.constInt(0, .void); } + /// Close a non-terminated `catch` handler block. `succ_ty` is the catch's + /// result type (`.void` for a pure-failable / void-chain catch — the merge + /// block then has no parameter). A `body_val` typed `noreturn` (e.g. a + /// `process.exit` / other noreturn call, which is NOT an IR terminator) + /// diverges: close with `unreachable` and skip the merge edge so its + /// "value" never reaches a phi. Otherwise clear the absorbed trace and + /// branch to the merge (coercing the body value, or diagnosing a missing / + /// void value for a value-carrying catch). + fn finishCatchHandler(self: *Lowering, body_val: ?Ref, succ_ty: TypeId, merge_bb: BlockId, span: ast.Span) void { + if (body_val) |v| { + if (self.builder.getRefType(v) == .noreturn) { + self.builder.emitUnreachable(); + return; + } + } + self.emitTraceClear(); + if (succ_ty == .void) { + self.builder.br(merge_bb, &.{}); + return; + } + const bv: Ref = blk: { + if (body_val) |v| { + const vty = self.builder.getRefType(v); + if (vty != .void) break :blk self.coerceToType(v, vty, succ_ty); + } + if (self.diagnostics) |diags| { + diags.addFmt(.err, span, "`catch` body must produce a value of type '{s}' (or diverge with `return` / `raise`)", .{self.formatTypeName(succ_ty)}); + } + break :blk self.builder.constUndef(succ_ty); + }; + self.builder.br(merge_bb, &.{bv}); + } + /// Lower a `catch` body in a child scope that binds the error tag to the /// catch binding (if any). When `want_ty` is non-null (value-carrying /// catch), returns the body's value (or null if the body diverged); when @@ -16349,7 +16363,12 @@ pub const Lowering = struct { fn ensureTerminator(self: *Lowering, ret_ty: TypeId) void { if (self.currentBlockHasTerminator()) return; - if (ret_ty == .void) { + if (ret_ty == .noreturn) { + // A `-> noreturn` function never returns; if control reaches the + // end of the body it's genuinely unreachable (the body is expected + // to diverge — call another noreturn, loop forever, etc.). + self.builder.emitUnreachable(); + } else if (ret_ty == .void) { self.builder.retVoid(); } else { // Use const_undef for complex types (string, struct, etc.) diff --git a/src/ir/print.zig b/src/ir/print.zig index b14a0b3..eb858e3 100644 --- a/src/ir/print.zig +++ b/src/ir/print.zig @@ -143,6 +143,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 : "), .const_type => |tid| try writer.print("const type({s}) : ", .{tt.typeName(tid)}), // ── Arithmetic ────────────────────────────────────────── diff --git a/tests/expected/248-log-and-comptime.exit b/tests/expected/248-log-and-comptime.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/248-log-and-comptime.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/248-log-and-comptime.txt b/tests/expected/248-log-and-comptime.txt new file mode 100644 index 0000000..51de13b --- /dev/null +++ b/tests/expected/248-log-and-comptime.txt @@ -0,0 +1,5 @@ +WARN: disk 91% full +INFO: user alice connected +ERROR: bad fd 7 +DEBUG: trace x=42 +[stdout] is_comptime runtime=2 comptime=1 diff --git a/tests/expected/249-process-exit.exit b/tests/expected/249-process-exit.exit new file mode 100644 index 0000000..d81cc07 --- /dev/null +++ b/tests/expected/249-process-exit.exit @@ -0,0 +1 @@ +42 diff --git a/tests/expected/249-process-exit.txt b/tests/expected/249-process-exit.txt new file mode 100644 index 0000000..711789c --- /dev/null +++ b/tests/expected/249-process-exit.txt @@ -0,0 +1 @@ +starting diff --git a/tests/expected/250-assert.exit b/tests/expected/250-assert.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/expected/250-assert.exit @@ -0,0 +1 @@ +1 diff --git a/tests/expected/250-assert.txt b/tests/expected/250-assert.txt new file mode 100644 index 0000000..63caee9 --- /dev/null +++ b/tests/expected/250-assert.txt @@ -0,0 +1,2 @@ +first assert passed +ASSERTION FAILED: two plus two is not five