roc/test/fx/platform/host.zig

802 lines
33 KiB
Zig

//! Platform host for testing effectful Roc applications.
//!
//! This host provides stdin/stdout/stderr effects and includes a test mode for
//! verifying IO behavior without performing actual syscalls.
//!
//! ## Test Mode
//!
//! Run with `--test <spec>` to simulate IO and verify behavior:
//! ```
//! ./zig-out/bin/roc app.roc -- --test "1>Hello, world!"
//! ```
//!
//! Spec format uses pipe-separated operations:
//! - `0<input` - provide "input" as stdin
//! - `1>output` - expect "output" on stdout
//! - `2>output` - expect "output" on stderr
//!
//! Example with multiple operations:
//! ```
//! --test "0<user input|1>Before stdin|1>After stdin"
//! ```
//!
//! Use `--test-verbose <spec>` for detailed output during test execution.
//!
//! Exit codes:
//! - 0: All expectations matched in order
//! - 1: Test failed (mismatch, missing output, extra output, or invalid spec)
const std = @import("std");
const builtin = @import("builtin");
const builtins = @import("builtins");
const build_options = @import("build_options");
const posix = if (builtin.os.tag != .windows and builtin.os.tag != .wasi) std.posix else undefined;
const trace_refcount = build_options.trace_refcount;
/// Error message to display on stack overflow in a Roc program
const STACK_OVERFLOW_MESSAGE = "\nThis Roc application overflowed its stack memory and crashed.\n\n";
/// Callback for stack overflow in a Roc program
fn handleRocStackOverflow() noreturn {
if (comptime builtin.os.tag == .windows) {
const DWORD = u32;
const HANDLE = ?*anyopaque;
const STD_ERROR_HANDLE: DWORD = @bitCast(@as(i32, -12));
const kernel32 = struct {
extern "kernel32" fn GetStdHandle(nStdHandle: DWORD) callconv(.winapi) HANDLE;
extern "kernel32" fn WriteFile(hFile: HANDLE, lpBuffer: [*]const u8, nNumberOfBytesToWrite: DWORD, lpNumberOfBytesWritten: ?*DWORD, lpOverlapped: ?*anyopaque) callconv(.winapi) i32;
extern "kernel32" fn ExitProcess(uExitCode: c_uint) callconv(.winapi) noreturn;
};
const stderr_handle = kernel32.GetStdHandle(STD_ERROR_HANDLE);
var bytes_written: DWORD = 0;
_ = kernel32.WriteFile(stderr_handle, STACK_OVERFLOW_MESSAGE.ptr, STACK_OVERFLOW_MESSAGE.len, &bytes_written, null);
kernel32.ExitProcess(134);
} else if (comptime builtin.os.tag != .wasi) {
_ = posix.write(posix.STDERR_FILENO, STACK_OVERFLOW_MESSAGE) catch {};
posix.exit(134);
} else {
std.process.exit(134);
}
}
/// Callback for access violation in a Roc program
fn handleRocAccessViolation(fault_addr: usize) noreturn {
if (comptime builtin.os.tag == .windows) {
const DWORD = u32;
const HANDLE = ?*anyopaque;
const STD_ERROR_HANDLE: DWORD = @bitCast(@as(i32, -12));
const kernel32 = struct {
extern "kernel32" fn GetStdHandle(nStdHandle: DWORD) callconv(.winapi) HANDLE;
extern "kernel32" fn WriteFile(hFile: HANDLE, lpBuffer: [*]const u8, nNumberOfBytesToWrite: DWORD, lpNumberOfBytesWritten: ?*DWORD, lpOverlapped: ?*anyopaque) callconv(.winapi) i32;
extern "kernel32" fn ExitProcess(uExitCode: c_uint) callconv(.winapi) noreturn;
};
var addr_buf: [18]u8 = undefined;
const addr_str = builtins.handlers.formatHex(fault_addr, &addr_buf);
const msg1 = "\nSegmentation fault (SIGSEGV) in this Roc program.\nFault address: ";
const msg2 = "\n\n";
const stderr_handle = kernel32.GetStdHandle(STD_ERROR_HANDLE);
var bytes_written: DWORD = 0;
_ = kernel32.WriteFile(stderr_handle, msg1.ptr, msg1.len, &bytes_written, null);
_ = kernel32.WriteFile(stderr_handle, addr_str.ptr, @intCast(addr_str.len), &bytes_written, null);
_ = kernel32.WriteFile(stderr_handle, msg2.ptr, msg2.len, &bytes_written, null);
kernel32.ExitProcess(139);
} else {
// POSIX (and WASI fallback)
const msg = "\nSegmentation fault (SIGSEGV) in this Roc program.\nFault address: ";
_ = posix.write(posix.STDERR_FILENO, msg) catch {};
var addr_buf: [18]u8 = undefined;
const addr_str = builtins.handlers.formatHex(fault_addr, &addr_buf);
_ = posix.write(posix.STDERR_FILENO, addr_str) catch {};
_ = posix.write(posix.STDERR_FILENO, "\n\n") catch {};
posix.exit(139);
}
}
/// Error message to display on division by zero in a Roc program
const DIVISION_BY_ZERO_MESSAGE = "\nThis Roc application divided by zero and crashed.\n\n";
/// Callback for arithmetic errors (division by zero) in a Roc program
fn handleRocArithmeticError() noreturn {
if (comptime builtin.os.tag == .windows) {
const DWORD = u32;
const HANDLE = ?*anyopaque;
const STD_ERROR_HANDLE: DWORD = @bitCast(@as(i32, -12));
const kernel32 = struct {
extern "kernel32" fn GetStdHandle(nStdHandle: DWORD) callconv(.winapi) HANDLE;
extern "kernel32" fn WriteFile(hFile: HANDLE, lpBuffer: [*]const u8, nNumberOfBytesToWrite: DWORD, lpNumberOfBytesWritten: ?*DWORD, lpOverlapped: ?*anyopaque) callconv(.winapi) i32;
extern "kernel32" fn ExitProcess(uExitCode: c_uint) callconv(.winapi) noreturn;
};
const stderr_handle = kernel32.GetStdHandle(STD_ERROR_HANDLE);
var bytes_written: DWORD = 0;
_ = kernel32.WriteFile(stderr_handle, DIVISION_BY_ZERO_MESSAGE.ptr, DIVISION_BY_ZERO_MESSAGE.len, &bytes_written, null);
kernel32.ExitProcess(136);
} else if (comptime builtin.os.tag != .wasi) {
_ = posix.write(posix.STDERR_FILENO, DIVISION_BY_ZERO_MESSAGE) catch {};
posix.exit(136); // 128 + 8 (SIGFPE)
} else {
std.process.exit(136);
}
}
/// Type of IO operation in test spec
const EffectType = enum(u8) {
stdin_input, // 0<
stdout_expect, // 1>
stderr_expect, // 2>
};
/// A single entry in the test spec
const SpecEntry = struct {
effect_type: EffectType,
value: []const u8,
spec_line: usize, // For error reporting
};
/// Test state for simulated IO mode
const TestState = struct {
enabled: bool,
verbose: bool,
entries: []const SpecEntry,
current_index: usize,
failed: bool,
failure_info: ?FailureInfo,
const FailureInfo = struct {
expected_type: EffectType,
expected_value: []const u8,
actual_type: EffectType,
actual_value: []const u8,
spec_line: usize,
};
fn init() TestState {
return .{
.enabled = false,
.verbose = false,
.entries = &.{},
.current_index = 0,
.failed = false,
.failure_info = null,
};
}
};
/// Parse error for invalid spec format
const ParseError = error{
InvalidSpecFormat,
OutOfMemory,
};
/// Parse test spec string into array of SpecEntry
/// Format: "0<input|1>output|2>error" (pipe-separated)
/// Returns error if any segment doesn't start with a valid pattern (0<, 1>, 2>)
fn parseTestSpec(allocator: std.mem.Allocator, spec: []const u8) ParseError![]SpecEntry {
var entries = std.ArrayList(SpecEntry).initCapacity(allocator, 8) catch return ParseError.OutOfMemory;
errdefer entries.deinit(allocator);
var line_num: usize = 1;
// Split on pipe character
var iter = std.mem.splitScalar(u8, spec, '|');
while (iter.next()) |segment| {
defer line_num += 1;
// Skip empty segments (e.g., trailing pipe)
if (segment.len == 0) continue;
// Check for valid pattern prefix
if (segment.len < 2) {
const stderr_file: std.fs.File = .stderr();
stderr_file.writeAll("Error: Invalid spec segment '") catch {};
stderr_file.writeAll(segment) catch {};
stderr_file.writeAll("' - must start with 0<, 1>, or 2>\n") catch {};
return ParseError.InvalidSpecFormat;
}
const effect_type: EffectType = blk: {
if (segment[0] == '0' and segment[1] == '<') break :blk .stdin_input;
if (segment[0] == '1' and segment[1] == '>') break :blk .stdout_expect;
if (segment[0] == '2' and segment[1] == '>') break :blk .stderr_expect;
// Invalid pattern - report error
const stderr_file: std.fs.File = .stderr();
stderr_file.writeAll("Error: Invalid spec segment '") catch {};
stderr_file.writeAll(segment) catch {};
stderr_file.writeAll("' - must start with 0<, 1>, or 2>\n") catch {};
return ParseError.InvalidSpecFormat;
};
entries.append(allocator, .{
.effect_type = effect_type,
.value = segment[2..],
.spec_line = line_num,
}) catch return ParseError.OutOfMemory;
}
return entries.toOwnedSlice(allocator) catch ParseError.OutOfMemory;
}
/// Host environment - contains GeneralPurposeAllocator for leak detection
const HostEnv = struct {
gpa: std.heap.GeneralPurposeAllocator(.{}),
test_state: TestState,
};
/// Roc allocation function with size-tracking metadata
fn rocAllocFn(roc_alloc: *builtins.host_abi.RocAlloc, env: *anyopaque) callconv(.c) void {
const host: *HostEnv = @ptrCast(@alignCast(env));
const allocator = host.gpa.allocator();
const align_enum = std.mem.Alignment.fromByteUnits(@as(usize, @intCast(roc_alloc.alignment)));
// Calculate additional bytes needed to store the size
const size_storage_bytes = @max(roc_alloc.alignment, @alignOf(usize));
const total_size = roc_alloc.length + size_storage_bytes;
// Allocate memory including space for size metadata
const result = allocator.rawAlloc(total_size, align_enum, @returnAddress());
const base_ptr = result orelse {
const stderr: std.fs.File = .stderr();
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "\x1b[31mHost error:\x1b[0m allocation failed for size={d} align={d}\n", .{
total_size,
roc_alloc.alignment,
}) catch "\x1b[31mHost error:\x1b[0m allocation failed, out of memory\n";
stderr.writeAll(msg) catch {};
std.process.exit(1);
};
// Store the total size (including metadata) right before the user data
const size_ptr: *usize = @ptrFromInt(@intFromPtr(base_ptr) + size_storage_bytes - @sizeOf(usize));
size_ptr.* = total_size;
// Return pointer to the user data (after the size metadata)
roc_alloc.answer = @ptrFromInt(@intFromPtr(base_ptr) + size_storage_bytes);
if (trace_refcount) {
std.debug.print("[ALLOC] ptr=0x{x} size={d} align={d}\n", .{ @intFromPtr(roc_alloc.answer), roc_alloc.length, roc_alloc.alignment });
}
}
/// Roc deallocation function with size-tracking metadata
fn rocDeallocFn(roc_dealloc: *builtins.host_abi.RocDealloc, env: *anyopaque) callconv(.c) void {
const host: *HostEnv = @ptrCast(@alignCast(env));
const allocator = host.gpa.allocator();
// Calculate where the size metadata is stored
const size_storage_bytes = @max(roc_dealloc.alignment, @alignOf(usize));
const size_ptr: *const usize = @ptrFromInt(@intFromPtr(roc_dealloc.ptr) - @sizeOf(usize));
const total_size = size_ptr.*;
if (trace_refcount) {
std.debug.print("[DEALLOC] ptr=0x{x} align={d} total_size={d} size_storage={d}\n", .{
@intFromPtr(roc_dealloc.ptr),
roc_dealloc.alignment,
total_size,
size_storage_bytes,
});
}
// Calculate the base pointer (start of actual allocation)
const base_ptr: [*]u8 = @ptrFromInt(@intFromPtr(roc_dealloc.ptr) - size_storage_bytes);
// Use same alignment calculation as alloc
const align_enum = std.mem.Alignment.fromByteUnits(@as(usize, @intCast(roc_dealloc.alignment)));
// Free the memory (including the size metadata)
const slice = @as([*]u8, @ptrCast(base_ptr))[0..total_size];
allocator.rawFree(slice, align_enum, @returnAddress());
}
/// Roc reallocation function with size-tracking metadata
fn rocReallocFn(roc_realloc: *builtins.host_abi.RocRealloc, env: *anyopaque) callconv(.c) void {
const host: *HostEnv = @ptrCast(@alignCast(env));
const allocator = host.gpa.allocator();
// Calculate where the size metadata is stored for the old allocation
const size_storage_bytes = @max(roc_realloc.alignment, @alignOf(usize));
const old_size_ptr: *const usize = @ptrFromInt(@intFromPtr(roc_realloc.answer) - @sizeOf(usize));
// Read the old total size from metadata
const old_total_size = old_size_ptr.*;
// Calculate the old base pointer (start of actual allocation)
const old_base_ptr: [*]u8 = @ptrFromInt(@intFromPtr(roc_realloc.answer) - size_storage_bytes);
// Calculate new total size needed
const new_total_size = roc_realloc.new_length + size_storage_bytes;
// Perform reallocation
const old_slice = @as([*]u8, @ptrCast(old_base_ptr))[0..old_total_size];
const new_slice = allocator.realloc(old_slice, new_total_size) catch {
const stderr: std.fs.File = .stderr();
stderr.writeAll("\x1b[31mHost error:\x1b[0m reallocation failed, out of memory\n") catch {};
std.process.exit(1);
};
// Store the new total size in the metadata
const new_size_ptr: *usize = @ptrFromInt(@intFromPtr(new_slice.ptr) + size_storage_bytes - @sizeOf(usize));
new_size_ptr.* = new_total_size;
// Return pointer to the user data (after the size metadata)
roc_realloc.answer = @ptrFromInt(@intFromPtr(new_slice.ptr) + size_storage_bytes);
if (trace_refcount) {
std.debug.print("[REALLOC] old=0x{x} new=0x{x} new_size={d}\n", .{ @intFromPtr(old_base_ptr) + size_storage_bytes, @intFromPtr(roc_realloc.answer), roc_realloc.new_length });
}
}
/// Roc debug function
fn rocDbgFn(roc_dbg: *const builtins.host_abi.RocDbg, env: *anyopaque) callconv(.c) void {
_ = env;
const message = roc_dbg.utf8_bytes[0..roc_dbg.len];
std.debug.print("ROC DBG: {s}\n", .{message});
}
/// Roc expect failed function
fn rocExpectFailedFn(roc_expect: *const builtins.host_abi.RocExpectFailed, env: *anyopaque) callconv(.c) void {
_ = env;
const source_bytes = roc_expect.utf8_bytes[0..roc_expect.len];
const trimmed = std.mem.trim(u8, source_bytes, " \t\n\r");
std.debug.print("Expect failed: {s}\n", .{trimmed});
}
/// Roc crashed function
fn rocCrashedFn(roc_crashed: *const builtins.host_abi.RocCrashed, env: *anyopaque) callconv(.c) noreturn {
_ = env;
const message = roc_crashed.utf8_bytes[0..roc_crashed.len];
const stderr: std.fs.File = .stderr();
var buf: [256]u8 = undefined;
var w = stderr.writer(&buf);
w.interface.print("\n\x1b[31mRoc crashed:\x1b[0m {s}\n", .{message}) catch {};
w.interface.flush() catch {};
std.process.exit(1);
}
// External symbols provided by the Roc runtime object file
// Follows RocCall ABI: ops, ret_ptr, then argument pointers
extern fn roc__main(ops: *builtins.host_abi.RocOps, ret_ptr: *anyopaque, arg_ptr: ?*anyopaque) callconv(.c) void;
// OS-specific entry point handling
comptime {
// Export main for all platforms
@export(&main, .{ .name = "main" });
// Windows MinGW/MSVCRT compatibility: export __main stub
if (@import("builtin").os.tag == .windows) {
@export(&__main, .{ .name = "__main" });
}
}
// Windows MinGW/MSVCRT compatibility stub
// The C runtime on Windows calls __main from main for constructor initialization
fn __main() callconv(.c) void {}
// C compatible main for runtime
fn main(argc: c_int, argv: [*][*:0]u8) callconv(.c) c_int {
// Parse --test or --test-verbose argument
var test_spec: ?[]const u8 = null;
var test_verbose: bool = false;
var i: usize = 1;
const arg_count: usize = @intCast(argc);
const stderr_file: std.fs.File = .stderr();
while (i < arg_count) : (i += 1) {
const arg = std.mem.span(argv[i]);
if (std.mem.eql(u8, arg, "--test-verbose")) {
if (i + 1 < arg_count) {
i += 1;
test_spec = std.mem.span(argv[i]);
test_verbose = true;
} else {
stderr_file.writeAll("Error: --test-verbose requires a spec argument\n") catch {};
return 1;
}
} else if (std.mem.eql(u8, arg, "--test")) {
if (i + 1 < arg_count) {
i += 1;
test_spec = std.mem.span(argv[i]);
} else {
stderr_file.writeAll("Error: --test requires a spec argument\n") catch {};
return 1;
}
} else if (arg.len >= 2 and arg[0] == '-' and arg[1] == '-') {
stderr_file.writeAll("Error: unknown flag '") catch {};
stderr_file.writeAll(arg) catch {};
stderr_file.writeAll("'\n") catch {};
stderr_file.writeAll("Usage: <app> [--test <spec>] [--test-verbose <spec>]\n") catch {};
return 1;
}
}
const exit_code = platform_main(test_spec, test_verbose) catch |err| {
stderr_file.writeAll("HOST ERROR: ") catch {};
stderr_file.writeAll(@errorName(err)) catch {};
stderr_file.writeAll("\n") catch {};
return 1;
};
return exit_code;
}
// Use the actual RocStr from builtins instead of defining our own
const RocStr = builtins.str.RocStr;
/// Hosted function: Stderr.line! (index 0 - sorted alphabetically)
/// Follows RocCall ABI: (ops, ret_ptr, args_ptr)
/// Returns {} and takes Str as argument
fn hostedStderrLine(ops: *builtins.host_abi.RocOps, ret_ptr: *anyopaque, args_ptr: *anyopaque) callconv(.c) void {
_ = ret_ptr; // Return value is {} which is zero-sized
// Arguments struct for single Str parameter
const Args = extern struct { str: RocStr };
const args: *Args = @ptrCast(@alignCast(args_ptr));
const message = args.str.asSlice();
const host: *HostEnv = @ptrCast(@alignCast(ops.env));
// Test mode: verify output matches expected
if (host.test_state.enabled) {
const stderr_file: std.fs.File = .stderr();
if (host.test_state.current_index < host.test_state.entries.len) {
const entry = host.test_state.entries[host.test_state.current_index];
if (entry.effect_type == .stderr_expect and std.mem.eql(u8, entry.value, message)) {
host.test_state.current_index += 1;
if (host.test_state.verbose) {
stderr_file.writeAll("[OK] stderr: \"") catch {};
stderr_file.writeAll(message) catch {};
stderr_file.writeAll("\"\n") catch {};
}
return; // Match!
}
// Mismatch - must allocate a copy of the message since the RocStr may be freed
const actual_copy = host.gpa.allocator().dupe(u8, message) catch "";
host.test_state.failed = true;
host.test_state.failure_info = .{
.expected_type = entry.effect_type,
.expected_value = entry.value,
.actual_type = .stderr_expect,
.actual_value = actual_copy,
.spec_line = entry.spec_line,
};
if (host.test_state.verbose) {
stderr_file.writeAll("[FAIL] stderr: \"") catch {};
stderr_file.writeAll(message) catch {};
stderr_file.writeAll("\" (expected ") catch {};
stderr_file.writeAll(effectTypeName(entry.effect_type)) catch {};
stderr_file.writeAll(": \"") catch {};
stderr_file.writeAll(entry.value) catch {};
stderr_file.writeAll("\")\n") catch {};
}
} else {
// Extra output not in spec - must allocate a copy of the message
const actual_copy = host.gpa.allocator().dupe(u8, message) catch "";
host.test_state.failed = true;
host.test_state.failure_info = .{
.expected_type = .stderr_expect, // We expected nothing
.expected_value = "",
.actual_type = .stderr_expect,
.actual_value = actual_copy,
.spec_line = 0,
};
if (host.test_state.verbose) {
stderr_file.writeAll("[FAIL] stderr: \"") catch {};
stderr_file.writeAll(message) catch {};
stderr_file.writeAll("\" (unexpected - no more expected operations)\n") catch {};
}
}
return;
}
// Normal mode: write to stderr
const stderr: std.fs.File = .stderr();
stderr.writeAll(message) catch {};
stderr.writeAll("\n") catch {};
}
/// Hosted function: Stdin.line! (index 1 - sorted alphabetically)
/// Follows RocCall ABI: (ops, ret_ptr, args_ptr)
/// Returns Str and takes {} as argument
fn hostedStdinLine(ops: *builtins.host_abi.RocOps, ret_ptr: *anyopaque, args_ptr: *anyopaque) callconv(.c) void {
_ = args_ptr; // Argument is {} which is zero-sized
const host: *HostEnv = @ptrCast(@alignCast(ops.env));
const result: *RocStr = @ptrCast(@alignCast(ret_ptr));
// Test mode: consume next stdin_input entry from spec
if (host.test_state.enabled) {
const stderr_file: std.fs.File = .stderr();
if (host.test_state.current_index < host.test_state.entries.len) {
const entry = host.test_state.entries[host.test_state.current_index];
if (entry.effect_type == .stdin_input) {
host.test_state.current_index += 1;
result.* = RocStr.fromSlice(entry.value, ops);
if (host.test_state.verbose) {
stderr_file.writeAll("[OK] stdin: \"") catch {};
stderr_file.writeAll(entry.value) catch {};
stderr_file.writeAll("\"\n") catch {};
}
return;
}
// Wrong type - expected stdin but spec has output
host.test_state.failed = true;
host.test_state.failure_info = .{
.expected_type = entry.effect_type,
.expected_value = entry.value,
.actual_type = .stdin_input,
.actual_value = "(stdin read)",
.spec_line = entry.spec_line,
};
if (host.test_state.verbose) {
stderr_file.writeAll("[FAIL] stdin read (expected ") catch {};
stderr_file.writeAll(effectTypeName(entry.effect_type)) catch {};
stderr_file.writeAll(": \"") catch {};
stderr_file.writeAll(entry.value) catch {};
stderr_file.writeAll("\")\n") catch {};
}
} else {
// Ran out of entries - app tried to read more stdin than provided
host.test_state.failed = true;
host.test_state.failure_info = .{
.expected_type = .stdin_input,
.expected_value = "",
.actual_type = .stdin_input,
.actual_value = "(stdin read)",
.spec_line = 0,
};
if (host.test_state.verbose) {
stderr_file.writeAll("[FAIL] stdin read (unexpected - no more expected operations)\n") catch {};
}
}
result.* = RocStr.empty();
return;
}
// Normal mode: Read a line from stdin
var buffer: [4096]u8 = undefined;
const stdin_file: std.fs.File = .stdin();
const bytes_read = stdin_file.read(&buffer) catch {
// Return empty string on error
result.* = RocStr.empty();
return;
};
// Handle EOF (no bytes read)
if (bytes_read == 0) {
result.* = RocStr.empty();
return;
}
// Find newline and trim it (handle both \n and \r\n)
const line_with_newline = buffer[0..bytes_read];
var line = if (std.mem.indexOfScalar(u8, line_with_newline, '\n')) |newline_idx|
line_with_newline[0..newline_idx]
else
line_with_newline;
// Also trim trailing \r for Windows line endings
if (line.len > 0 and line[line.len - 1] == '\r') {
line = line[0 .. line.len - 1];
}
// Create RocStr from the read line and return it
// RocStr.fromSlice handles allocation internally (either inline for small strings
// or via roc_alloc for big strings with proper refcount tracking)
result.* = RocStr.fromSlice(line, ops);
}
/// Hosted function: Stdout.line! (index 2 - sorted alphabetically)
/// Follows RocCall ABI: (ops, ret_ptr, args_ptr)
/// Returns {} and takes Str as argument
fn hostedStdoutLine(ops: *builtins.host_abi.RocOps, ret_ptr: *anyopaque, args_ptr: *anyopaque) callconv(.c) void {
_ = ret_ptr; // Return value is {} which is zero-sized
// Arguments struct for single Str parameter
const Args = extern struct { str: RocStr };
const args: *Args = @ptrCast(@alignCast(args_ptr));
const message = args.str.asSlice();
const host: *HostEnv = @ptrCast(@alignCast(ops.env));
// Test mode: verify output matches expected
if (host.test_state.enabled) {
const stderr_file: std.fs.File = .stderr();
if (host.test_state.current_index < host.test_state.entries.len) {
const entry = host.test_state.entries[host.test_state.current_index];
if (entry.effect_type == .stdout_expect and std.mem.eql(u8, entry.value, message)) {
host.test_state.current_index += 1;
if (host.test_state.verbose) {
stderr_file.writeAll("[OK] stdout: \"") catch {};
stderr_file.writeAll(message) catch {};
stderr_file.writeAll("\"\n") catch {};
}
return; // Match!
}
// Mismatch - must allocate a copy of the message since the RocStr may be freed
const actual_copy = host.gpa.allocator().dupe(u8, message) catch "";
host.test_state.failed = true;
host.test_state.failure_info = .{
.expected_type = entry.effect_type,
.expected_value = entry.value,
.actual_type = .stdout_expect,
.actual_value = actual_copy,
.spec_line = entry.spec_line,
};
if (host.test_state.verbose) {
stderr_file.writeAll("[FAIL] stdout: \"") catch {};
stderr_file.writeAll(message) catch {};
stderr_file.writeAll("\" (expected ") catch {};
stderr_file.writeAll(effectTypeName(entry.effect_type)) catch {};
stderr_file.writeAll(": \"") catch {};
stderr_file.writeAll(entry.value) catch {};
stderr_file.writeAll("\")\n") catch {};
}
} else {
// Extra output not in spec - must allocate a copy of the message
const actual_copy = host.gpa.allocator().dupe(u8, message) catch "";
host.test_state.failed = true;
host.test_state.failure_info = .{
.expected_type = .stdout_expect, // We expected nothing
.expected_value = "",
.actual_type = .stdout_expect,
.actual_value = actual_copy,
.spec_line = 0,
};
if (host.test_state.verbose) {
stderr_file.writeAll("[FAIL] stdout: \"") catch {};
stderr_file.writeAll(message) catch {};
stderr_file.writeAll("\" (unexpected - no more expected operations)\n") catch {};
}
}
return;
}
// Normal mode: write to stdout
const stdout: std.fs.File = .stdout();
stdout.writeAll(message) catch {};
stdout.writeAll("\n") catch {};
}
/// Array of hosted function pointers, sorted alphabetically by fully-qualified name
/// These correspond to the hosted functions defined in Stderr, Stdin, and Stdout Type Modules
const hosted_function_ptrs = [_]builtins.host_abi.HostedFn{
hostedStderrLine, // Stderr.line! (index 0)
hostedStdinLine, // Stdin.line! (index 1)
hostedStdoutLine, // Stdout.line! (index 2)
};
/// Platform host entrypoint
fn platform_main(test_spec: ?[]const u8, test_verbose: bool) !c_int {
// Install signal handlers for stack overflow, access violations, and division by zero
// This allows us to display helpful error messages instead of crashing
_ = builtins.handlers.install(handleRocStackOverflow, handleRocAccessViolation, handleRocArithmeticError);
var host_env = HostEnv{
.gpa = std.heap.GeneralPurposeAllocator(.{}){},
.test_state = TestState.init(),
};
// Parse test spec if provided
if (test_spec) |spec| {
host_env.test_state.entries = try parseTestSpec(host_env.gpa.allocator(), spec);
host_env.test_state.enabled = true;
host_env.test_state.verbose = test_verbose;
}
defer {
// Free duplicated actual_value if allocated (on test failure)
if (host_env.test_state.failure_info) |info| {
if (info.actual_value.len > 0) {
host_env.gpa.allocator().free(info.actual_value);
}
}
// Free test entries if allocated
if (host_env.test_state.entries.len > 0) {
host_env.gpa.allocator().free(host_env.test_state.entries);
}
const leaked = host_env.gpa.deinit();
if (leaked == .leak) {
std.log.err("\x1b[33mMemory leak detected!\x1b[0m", .{});
}
}
// Create the RocOps struct
var roc_ops = builtins.host_abi.RocOps{
.env = @as(*anyopaque, @ptrCast(&host_env)),
.roc_alloc = rocAllocFn,
.roc_dealloc = rocDeallocFn,
.roc_realloc = rocReallocFn,
.roc_dbg = rocDbgFn,
.roc_expect_failed = rocExpectFailedFn,
.roc_crashed = rocCrashedFn,
.hosted_fns = .{
.count = hosted_function_ptrs.len,
.fns = @constCast(&hosted_function_ptrs),
},
};
// Call the app's main! entrypoint
var ret: [0]u8 = undefined; // Result is {} which is zero-sized
var args: [0]u8 = undefined;
// Note: although this is a function with no args and a zero-sized return value,
// we can't currently pass null pointers for either of these because Roc will
// currently dereference both of these eagerly even though it won't use either,
// causing a segfault if you pass null. This should be changed! Dereferencing
// garbage memory is obviously pointless, and there's no reason we should do it.
roc__main(&roc_ops, @as(*anyopaque, @ptrCast(&ret)), @as(*anyopaque, @ptrCast(&args)));
// Check test results if in test mode
if (host_env.test_state.enabled) {
// Check if test failed or not all entries were consumed
if (host_env.test_state.failed or host_env.test_state.current_index != host_env.test_state.entries.len) {
const stderr_file: std.fs.File = .stderr();
// Print failure info
if (host_env.test_state.failure_info) |info| {
if (info.spec_line == 0) {
// Extra/unexpected output
stderr_file.writeAll("TEST FAILED: Unexpected ") catch {};
stderr_file.writeAll(effectTypeName(info.actual_type)) catch {};
stderr_file.writeAll(" output: \"") catch {};
stderr_file.writeAll(info.actual_value) catch {};
stderr_file.writeAll("\"\n") catch {};
} else {
var buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "TEST FAILED at spec line {d}:\n Expected: {s} \"{s}\"\n Got: {s} \"{s}\"\n", .{
info.spec_line,
effectTypeName(info.expected_type),
info.expected_value,
effectTypeName(info.actual_type),
info.actual_value,
}) catch "TEST FAILED\n";
stderr_file.writeAll(msg) catch {};
}
} else if (host_env.test_state.current_index < host_env.test_state.entries.len) {
// Not all entries were consumed - list what's remaining
const remaining = host_env.test_state.entries.len - host_env.test_state.current_index;
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "TEST FAILED: {d} expected IO operation(s) not performed:\n", .{remaining}) catch "TEST FAILED: expected IO operations not performed\n";
stderr_file.writeAll(msg) catch {};
// List up to 5 unconsumed entries
const max_to_show: usize = 5;
var shown: usize = 0;
for (host_env.test_state.entries[host_env.test_state.current_index..]) |entry| {
if (shown >= max_to_show) {
stderr_file.writeAll(" ...\n") catch {};
break;
}
stderr_file.writeAll(" - ") catch {};
stderr_file.writeAll(effectTypeName(entry.effect_type)) catch {};
stderr_file.writeAll(": \"") catch {};
stderr_file.writeAll(entry.value) catch {};
stderr_file.writeAll("\"\n") catch {};
shown += 1;
}
} else {
stderr_file.writeAll("TEST FAILED\n") catch {};
}
return 1;
}
}
return 0;
}
fn effectTypeName(effect_type: EffectType) []const u8 {
return switch (effect_type) {
.stdin_input => "stdin",
.stdout_expect => "stdout",
.stderr_expect => "stderr",
};
}