diff --git a/crates/haku-wasm/src/lib.rs b/crates/haku-wasm/src/lib.rs index 7417839..0e21b02 100644 --- a/crates/haku-wasm/src/lib.rs +++ b/crates/haku-wasm/src/lib.rs @@ -2,7 +2,7 @@ extern crate alloc; -use core::{alloc::Layout, mem, slice}; +use core::{alloc::Layout, mem, ptr, slice}; use alloc::{boxed::Box, string::String, vec::Vec}; use haku::{ @@ -12,7 +12,7 @@ use haku::{ diagnostic::Diagnostic, lexer::{lex, Lexer}, parser::{self, IntoAstError, Parser}, - source::SourceCode, + source::{SourceCode, Span}, token::Lexis, }; use log::{debug, info}; @@ -449,6 +449,22 @@ unsafe extern "C" fn haku_compile_brush( ); debug!("compiling: {closure_spec:?}"); + debug!("bytecode: {:?}", chunk.bytecode); + { + let mut cursor = 0_usize; + for info in &chunk.span_info { + let slice = &chunk.bytecode[cursor..cursor + info.len as usize]; + debug!( + "{:?} | 0x{:x} {:?} | {:?}", + info.span, + cursor, + slice, + info.span.slice(src.code), + ); + cursor += info.len as usize; + } + } + instance.compile_result2 = Some(CompileResult { defs_string: instance.defs.serialize_defs(), tags_string: instance.defs.serialize_tags(), @@ -528,6 +544,25 @@ unsafe extern "C" fn haku_bytecode(instance: *const Instance) -> *const u8 { unwrap_compile_result(instance).chunk.bytecode.as_ptr() } +#[unsafe(no_mangle)] +unsafe extern "C" fn haku_bytecode_find_span(instance: *const Instance, pc: u16) -> *const Span { + let chunk = &unwrap_compile_result(instance).chunk; + match chunk.find_span(pc) { + Some(r) => r, + None => ptr::null(), + } +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn haku_span_start(span: *const Span) -> u32 { + (*span).start +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn haku_span_end(span: *const Span) -> u32 { + (*span).end +} + #[unsafe(no_mangle)] unsafe extern "C" fn haku_local_count(instance: *const Instance) -> u8 { unwrap_compile_result(instance).closure_spec.local_count diff --git a/crates/haku/src/bytecode.rs b/crates/haku/src/bytecode.rs index 60a6a35..026c1b3 100644 --- a/crates/haku/src/bytecode.rs +++ b/crates/haku/src/bytecode.rs @@ -1,10 +1,9 @@ -use core::{ - fmt::{self, Display}, - mem::transmute, -}; +use core::fmt::{self, Display}; use alloc::{borrow::ToOwned, string::String, vec::Vec}; +use crate::source::Span; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] pub enum Opcode { @@ -56,6 +55,14 @@ pub const CAPTURE_CAPTURE: u8 = 1; #[derive(Debug, Clone)] pub struct Chunk { pub bytecode: Vec, + pub span_info: Vec, + pub current_span: Span, +} + +#[derive(Debug, Clone)] +pub struct SpanRun { + pub span: Span, + pub len: u16, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -66,6 +73,8 @@ impl Chunk { if capacity <= (1 << 16) { Ok(Chunk { bytecode: Vec::with_capacity(capacity), + span_info: Vec::new(), + current_span: Span::new(0, 0), }) } else { Err(ChunkSizeError) @@ -76,6 +85,16 @@ impl Chunk { Offset(self.bytecode.len() as u16) } + fn push_span_info(&mut self, span: Span, len: u16) { + if let Some(info) = self.span_info.last_mut() { + if info.span == span { + info.len += len; + return; + } + } + self.span_info.push(SpanRun { span, len }); + } + pub fn emit_bytes(&mut self, bytes: &[u8]) -> Result { if self.bytecode.len() + bytes.len() > self.bytecode.capacity() { return Err(EmitError); @@ -83,6 +102,7 @@ impl Chunk { let offset = Offset(self.bytecode.len() as u16); self.bytecode.extend_from_slice(bytes); + self.push_span_info(self.current_span, bytes.len() as u16); Ok(offset) } @@ -122,40 +142,15 @@ impl Chunk { self.patch_u16(offset, x.0); } - // NOTE: I'm aware these aren't the fastest implementations since they validate quite a lot - // during runtime, but this is just an MVP. It doesn't have to be blazingly fast. - - pub fn read_u8(&self, pc: &mut usize) -> Result { - let x = self.bytecode.get(*pc).copied(); - *pc += 1; - x.ok_or(ReadError) - } - - pub fn read_u16(&self, pc: &mut usize) -> Result { - let xs = &self.bytecode[*pc..*pc + 2]; - *pc += 2; - Ok(u16::from_le_bytes(xs.try_into().map_err(|_| ReadError)?)) - } - - pub fn read_u32(&self, pc: &mut usize) -> Result { - let xs = &self.bytecode[*pc..*pc + 4]; - *pc += 4; - Ok(u32::from_le_bytes(xs.try_into().map_err(|_| ReadError)?)) - } - - pub fn read_f32(&self, pc: &mut usize) -> Result { - let xs = &self.bytecode[*pc..*pc + 4]; - *pc += 4; - Ok(f32::from_le_bytes(xs.try_into().map_err(|_| ReadError)?)) - } - - pub fn read_opcode(&self, pc: &mut usize) -> Result { - let x = self.read_u8(pc)?; - if x <= Opcode::Return as u8 { - Ok(unsafe { transmute::(x) }) - } else { - Err(ReadError) + pub fn find_span(&self, pc: u16) -> Option<&Span> { + let mut cur = 0; + for info in &self.span_info { + if pc >= cur && pc < cur + info.len { + return Some(&info.span); + } + cur += info.len; } + None } } @@ -183,15 +178,6 @@ impl Display for EmitError { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReadError; - -impl Display for ReadError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "invalid bytecode: out of bounds read or invalid opcode") - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct DefId(u16); diff --git a/crates/haku/src/compiler.rs b/crates/haku/src/compiler.rs index 7b69e96..a095dcb 100644 --- a/crates/haku/src/compiler.rs +++ b/crates/haku/src/compiler.rs @@ -1,9 +1,14 @@ use core::{ error::Error, fmt::{self, Display}, + mem::take, }; -use alloc::vec::Vec; +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; use crate::{ ast::{Ast, NodeId, NodeKind}, @@ -42,6 +47,11 @@ pub struct Compiler<'a> { pub chunk: &'a mut Chunk, pub diagnostics: Vec, scopes: Vec>, + // NOTE: This mechanism is kind of non-obvious at first, but keep in mind this is the name of + // any functions emitted within the current scope---not of the current function. + // The convention is to set this to the current def's name, and any functions inside the current + // function get λ as a suffix. The more nesting, the more lambdas---e.g. myFunctionλλ. + function_name: String, } #[derive(Debug, Clone, Copy)] @@ -60,6 +70,8 @@ impl<'a> Compiler<'a> { captures: Vec::new(), let_count: 0, }]), + // For unnamed functions within the brush's toplevel. + function_name: "(brush)λ".into(), } } @@ -86,7 +98,10 @@ impl<'a> Compiler<'a> { type CompileResult = Result; pub fn compile_expr<'a>(c: &mut Compiler<'a>, src: &Source<'a>, node_id: NodeId) -> CompileResult { - match src.ast.kind(node_id) { + let previous_span = c.chunk.current_span; + c.chunk.current_span = src.ast.span(node_id); + + let result = match src.ast.kind(node_id) { // The nil node is special, as it inhabits node ID 0. NodeKind::Nil => { unreachable!("Nil node should never be emitted (ParenEmpty is used for nil literals)") @@ -121,7 +136,11 @@ pub fn compile_expr<'a>(c: &mut Compiler<'a>, src: &Source<'a>, node_id: NodeId) // Error nodes are ignored, because for each error node an appropriate parser // diagnostic is emitted anyways. NodeKind::Error => Ok(()), - } + }; + + c.chunk.current_span = previous_span; + + result } fn compile_nil(c: &mut Compiler) -> CompileResult { @@ -518,7 +537,17 @@ fn compile_lambda<'a>(c: &mut Compiler<'a>, src: &Source<'a>, node_id: NodeId) - c.chunk.emit_opcode(Opcode::Function)?; c.chunk.emit_u8(param_count)?; - let after_offset = c.chunk.emit_u16(0)?; + let then_offset = c.chunk.emit_u16(0)?; + + let function_name = &c.function_name[0..c.function_name.len().min(u8::MAX as usize)]; + c.chunk.emit_u8(function_name.len() as u8)?; + c.chunk.emit_bytes(function_name.as_bytes())?; + + // Swap the current name for a name with a λ at the end. + // This is to make lambdas within the current lambda shown as a different functions in + // stack traces. + let previous_function_name = take(&mut c.function_name); + c.function_name = format!("{previous_function_name}λ"); c.scopes.push(Scope { locals, @@ -528,8 +557,10 @@ fn compile_lambda<'a>(c: &mut Compiler<'a>, src: &Source<'a>, node_id: NodeId) - compile_expr(c, src, body)?; c.chunk.emit_opcode(Opcode::Return)?; - let after = u16::try_from(c.chunk.bytecode.len()).expect("chunk is too large"); - c.chunk.patch_u16(after_offset, after); + c.function_name = previous_function_name; + + let then = u16::try_from(c.chunk.bytecode.len()).expect("chunk is too large"); + c.chunk.patch_u16(then_offset, then); let scope = c.scopes.pop().unwrap(); let let_count = u8::try_from(scope.let_count).unwrap_or_else(|_| { @@ -580,6 +611,7 @@ fn compile_toplevel<'a>(c: &mut Compiler<'a>, src: &Source<'a>, node_id: NodeId) )); } + c.chunk.current_span = src.ast.span(toplevel_expr); match compile_toplevel_expr(c, src, toplevel_expr)? { ToplevelExpr::Def => (), ToplevelExpr::Result if result_expr.is_none() => result_expr = Some(toplevel_expr), @@ -676,10 +708,20 @@ fn compile_def<'a>(c: &mut Compiler<'a>, src: &Source<'a>, node_id: NodeId) -> C // zero def instead. let def_id = c.defs.get_def(name).unwrap_or_default(); + let previous_function_name = take(&mut c.function_name); + if src.ast.kind(right) == NodeKind::Lambda { + c.function_name = name.to_string(); + } else { + // Name for lambdas within the current def, which is not a lambda itself. + c.function_name = format!("{name}λ"); + } + compile_expr(c, src, right)?; c.chunk.emit_opcode(Opcode::SetDef)?; c.chunk.emit_u16(def_id.to_u16())?; + c.function_name = previous_function_name; + Ok(()) } diff --git a/crates/haku2/src/haku2.zig b/crates/haku2/src/haku2.zig index 111b77e..15dc3b1 100644 --- a/crates/haku2/src/haku2.zig +++ b/crates/haku2/src/haku2.zig @@ -22,7 +22,10 @@ pub const std_options: std.Options = .{ }; pub fn enableLogScope(scope: @TypeOf(.enum_literal)) bool { - if (scope == .vm) return false else return true; + if (scope == .vm) + return false + else + return true; } // Allocator @@ -124,14 +127,21 @@ export fn haku2_vm_run_main( chunk.* = bytecode.Chunk{ .bytecode = code[0..code_len], }; - const closure = value.Closure{ - .chunk = chunk, - .start = 0, - .param_count = 0, - .local_count = local_count, - .captures = &[_]value.Value{}, + const closure = scratch.allocator().create(value.Closure) catch { + vm.outOfMemory() catch {}; + return false; }; - vm.run(scratch.allocator(), &closure, vm.stack_top) catch return false; + closure.* = value.Closure{ + .name = "(brush)", + .param_count = 0, + .impl = .{ .bytecode = .{ + .chunk = chunk, + .start = 0, + .local_count = local_count, + .captures = &[_]value.Value{}, + } }, + }; + vm.run(scratch.allocator(), closure, vm.stack_top) catch return false; return true; } @@ -182,6 +192,33 @@ export fn haku2_vm_exception_render(vm: *const Vm, buffer: [*]u8) void { _ = exn.format(buffer[0..exn.len], &exn.args); } +export fn haku2_vm_stackframe_count(vm: *const Vm) usize { + return vm.stackFrameCount(); +} + +export fn haku2_vm_stackframe_is_system(vm: *const Vm, index: usize) bool { + const stack_frame = vm.stackFrame(index); + return stack_frame.closure.impl == .system; +} + +export fn haku2_vm_stackframe_pc(vm: *const Vm, index: usize) i32 { + const stack_frame = vm.stackFrame(index); + if (stack_frame.closure.impl == .bytecode) + if (stack_frame.ip) |ip| + return stack_frame.closure.programCounter(ip); + return -1; // no return address +} + +export fn haku2_vm_stackframe_function_name_len(vm: *const Vm, index: usize) usize { + const stack_frame = vm.stackFrame(index); + return stack_frame.closure.name.len; +} + +export fn haku2_vm_stackframe_function_name(vm: *const Vm, index: usize) [*]const u8 { + const stack_frame = vm.stackFrame(index); + return stack_frame.closure.name.ptr; +} + // Renderer export fn haku2_render(vm: *Vm, canvas: *Canvas, max_depth: usize) bool { diff --git a/crates/haku2/src/system.zig b/crates/haku2/src/system.zig index 464d1ba..e165031 100644 --- a/crates/haku2/src/system.zig +++ b/crates/haku2/src/system.zig @@ -62,7 +62,12 @@ pub const Context = struct { } }; -pub const Fn = *const fn (Context) Vm.Error!Value; +pub const Fn = struct { + pub const Impl = *const fn (Context) Vm.Error!Value; + + closure: value.Closure, + impl: Impl, +}; pub const FnTable = [256]Fn; fn invalid(cx: Context) !Value { @@ -150,13 +155,24 @@ fn intoReturn(cx: Context, any: anytype) Vm.Error!Value { }; } +fn rawFn(name: []const u8, func: Fn.Impl) Fn { + return .{ + .closure = .{ + .name = name, + .param_count = 0, // TODO + .impl = .{ .system = void{} }, + }, + .impl = func, + }; +} + /// Erase a well-typed function into a function that operates on raw values. /// The erased function performs all the necessary conversions automatically. /// /// Note that the argument order is important---function arguments go first, then context (such as /// the VM or the allocator.) Otherwise argument indices will not match up. fn erase(comptime name: []const u8, comptime func: anytype) Fn { - return Erased(name, func).call; + return rawFn(name, Erased(name, func).call); } fn countParams(comptime func: anytype) usize { @@ -191,7 +207,7 @@ const SparseFn = struct { u8, Fn }; const SparseFnTable = []const SparseFn; fn makeFnTable(init: SparseFnTable) FnTable { - var table = [_]Fn{invalid} ** @typeInfo(FnTable).array.len; + var table = [_]Fn{rawFn("(invalid)", invalid)} ** @typeInfo(FnTable).array.len; for (init) |entry| { const index, const func = entry; table[index] = func; @@ -249,12 +265,12 @@ pub const fns = makeFnTable(&[_]SparseFn{ .{ 0x44, erase("<=", lessOrEqual) }, .{ 0x45, erase(">", greater) }, .{ 0x46, erase(">=", greaterOrEqual) }, - .{ 0x80, vec }, + .{ 0x80, rawFn("vec", vec) }, .{ 0x81, erase("vecX", vecX) }, .{ 0x82, erase("vecY", vecY) }, .{ 0x83, erase("vecZ", vecZ) }, .{ 0x84, erase("vecW", vecW) }, - .{ 0x85, rgba }, + .{ 0x85, rawFn("rgba", rgba) }, .{ 0x86, erase("rgbaR", rgbaR) }, .{ 0x87, erase("rgbaG", rgbaG) }, .{ 0x88, erase("rgbaB", rgbaB) }, diff --git a/crates/haku2/src/value.zig b/crates/haku2/src/value.zig index 27ed963..2de5d2e 100644 --- a/crates/haku2/src/value.zig +++ b/crates/haku2/src/value.zig @@ -77,7 +77,7 @@ pub const Value = union(enum) { inline .tag, .number => |x| try std.fmt.format(writer, "{d}", .{x}), inline .vec4, .rgba => |x| try std.fmt.format(writer, "{s}{d}", .{ @tagName(value), x }), .ref => |ref| switch (ref.*) { - .closure => |c| try std.fmt.format(writer, "function({})", .{c.param_count}), + .closure => |c| try std.fmt.format(writer, "function {s}", .{c.name}), .list => |l| { try std.fmt.formatBuf("[", options, writer); for (l, 0..) |elem, i| { @@ -128,11 +128,31 @@ pub const Ref = union(enum) { }; pub const Closure = struct { - chunk: *const bytecode.Chunk, - start: bytecode.Loc, + pub const Impl = union(enum) { + bytecode: Bytecode, + // Currently unimplemented; only used as a discriminator for system functions on + // the call stack that do not have bytecode. + system: void, + + pub const Bytecode = struct { + chunk: *const bytecode.Chunk, + start: bytecode.Loc, + local_count: u8, + captures: []Value, + }; + }; + + name: []const u8, param_count: u8, - local_count: u8, - captures: []Value, + impl: Impl, + + pub fn bytecodePtr(c: *const Closure, pc: usize) [*]const u8 { + return c.impl.bytecode.chunk.bytecode[c.impl.bytecode.start + pc ..].ptr; + } + + pub fn programCounter(c: *const Closure, ip: [*]const u8) u16 { + return @truncate(ip - c.impl.bytecode.chunk.bytecode.ptr); + } }; pub const List = []Value; diff --git a/crates/haku2/src/vm.zig b/crates/haku2/src/vm.zig index 970d672..c08c8e3 100644 --- a/crates/haku2/src/vm.zig +++ b/crates/haku2/src/vm.zig @@ -19,6 +19,10 @@ call_stack_top: u32 = 0, defs: []Value = &.{}, fuel: u32 = 0, // NOTE: VM must be refueled via reset() before running code exception: ?Exception = null, +// closure, ip are for exception reporting. These variables are read-only, system functions +// cannot affect control flow using them. +closure: ?*const value.Closure = null, +ip: ?[*]const u8 = null, pub const Limits = struct { stack_capacity: usize = 256, @@ -27,7 +31,7 @@ pub const Limits = struct { pub const CallFrame = struct { closure: *const value.Closure, - ip: [*]const u8, + return_addr: ?[*]const u8, bottom: u32, }; @@ -59,8 +63,10 @@ pub fn reset(vm: *Vm, fuel: u32) void { vm.exception = null; } +// NOTE: When the VM throws an exception, it must be reset. There's no resuming from an exception. pub fn throw(vm: *Vm, comptime fmt: []const u8, args: anytype) Error { log.info("throw: fmt={s}", .{fmt}); + log.debug("implicit stack frame: closure={?any} ip={?*}", .{ vm.closure, vm.ip }); const Args = @TypeOf(args); const max_args_size = @sizeOf(@TypeOf(vm.exception.?.args)); @@ -92,6 +98,45 @@ pub fn outOfMemory(vm: *Vm) Error { return vm.throw("out of memory", .{}); } +// NOTE: Different from CallFrame, this is a helper type for obtaining stack traces. +pub const StackFrame = struct { + closure: *const value.Closure, + ip: ?[*]const u8, +}; + +pub fn stackFrameCount(vm: *const Vm) usize { + if (vm.closure != null) { + return vm.call_stack_top + 1; + } else { + return vm.call_stack_top; + } +} + +pub fn stackFrame(vm: *const Vm, index: usize) StackFrame { + if (vm.closure) |closure| { + if (index == 0) { + return .{ + .closure = closure, + .ip = vm.ip, + }; + } else { + // NOTE: Minus two, because remember index == 0 is occupied by the current stack frame. + const call_frame_index = vm.call_stack_top - 1 - (index - 1); + const call_frame = vm.call_stack[call_frame_index]; + return .{ + .closure = call_frame.closure, + .ip = call_frame.return_addr, + }; + } + } else { + const call_frame = vm.call_stack[vm.call_stack_top - 1 - index]; + return .{ + .closure = call_frame.closure, + .ip = call_frame.return_addr, + }; + } +} + /// Debug assertion for bytecode validity. /// In future versions, this may become disabled in release builds. fn validateBytecode(vm: *Vm, ok: bool, comptime fmt: []const u8, args: anytype) Error!void { @@ -162,16 +207,22 @@ pub fn def(vm: *Vm, index: u16) Error!*Value { // Utility struct for storing and restoring the VM's state variables across FFI boundaries. const Context = struct { + ip: ?[*]const u8, + closure: ?*const value.Closure, fuel: u32, }; fn restoreContext(vm: *Vm) Context { return .{ + .ip = vm.ip, + .closure = vm.closure, .fuel = vm.fuel, }; } fn storeContext(vm: *Vm, context: Context) void { + vm.ip = context.ip; + vm.closure = context.closure; vm.fuel = context.fuel; } @@ -181,7 +232,8 @@ inline fn read(comptime T: type, ip: *[*]const u8) T { return result; } -inline fn readOpcode(ip: *[*]const u8) bytecode.Opcode { +inline fn readOpcode(vm: *Vm, ip: *[*]const u8) bytecode.Opcode { + vm.ip = ip.*; const opcode: bytecode.Opcode = @enumFromInt(read(u8, ip)); log.debug("OP {*} {}", .{ ip.*, opcode }); return opcode; @@ -200,53 +252,59 @@ pub fn run( ) Error!void { log.debug("BEGIN RUN {}", .{init_closure}); + // NOTE: This will need swapping out once we implement first-class system functions, because + // system functions should be able to call themselves (like: map (range 1 10) sin) + debug.assert(init_closure.impl == .bytecode); + var closure = init_closure; - var ip: [*]const u8 = closure.chunk.bytecode[closure.start..].ptr; + var ip: [*]const u8 = closure.bytecodePtr(0); var bottom = init_bottom; var fuel = vm.fuel; - for (0..closure.local_count) |_| { + vm.closure = closure; + vm.ip = ip; + + for (0..closure.impl.bytecode.local_count) |_| { try vm.push(.nil); } - const call_bottom = vm.call_stack_top; try vm.pushCall(.{ .closure = closure, - .ip = ip, + .return_addr = null, // nowhere to return to .bottom = bottom, }); - next: switch (readOpcode(&ip)) { + next: switch (vm.readOpcode(&ip)) { .nil => { try vm.consumeFuel(&fuel, 1); try vm.push(.nil); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .false => { try vm.consumeFuel(&fuel, 1); try vm.push(.false); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .true => { try vm.consumeFuel(&fuel, 1); try vm.push(.true); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .tag => { try vm.consumeFuel(&fuel, 1); const tag_id: value.TagId = @enumFromInt(read(u16, &ip)); try vm.push(.{ .tag = tag_id }); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .number => { try vm.consumeFuel(&fuel, 1); const number: f32 = @bitCast(read(u32, &ip)); try vm.push(.{ .number = number }); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .rgba => { @@ -256,7 +314,7 @@ pub fn run( const b = read(u8, &ip); const a = read(u8, &ip); try vm.push(.{ .rgba = value.rgbaFrom8(value.Rgba8{ r, g, b, a }) }); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .local => { @@ -264,7 +322,7 @@ pub fn run( const index = read(u8, &ip); const l = try vm.local(bottom, index); try vm.push(l.*); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .set_local => { @@ -273,16 +331,17 @@ pub fn run( const new = try vm.pop(); const l = try vm.local(bottom, index); l.* = new; - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .capture => { try vm.consumeFuel(&fuel, 1); const index = read(u8, &ip); - try vm.validateBytecode(index < closure.captures.len, "capture index out of bounds", .{}); - const capture = closure.captures[index]; + const captures = closure.impl.bytecode.captures; + try vm.validateBytecode(index < captures.len, "capture index out of bounds", .{}); + const capture = captures[index]; try vm.push(capture); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .def => { @@ -290,7 +349,7 @@ pub fn run( const index = read(u16, &ip); const d = try vm.def(index); try vm.push(d.*); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .set_def => { @@ -299,7 +358,7 @@ pub fn run( const new = try vm.pop(); const d = try vm.def(index); d.* = new; - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .list => { @@ -312,62 +371,70 @@ pub fn run( const ref = allocator.create(value.Ref) catch return vm.outOfMemory(); ref.* = .{ .list = list }; try vm.push(.{ .ref = ref }); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .function => { - try vm.consumeFuel(&fuel, 1); const param_count = read(u8, &ip); const then = read(u16, &ip); + const name_len = read(u8, &ip); + const name = ip[0..name_len]; + ip += name_len; const body = ip; - ip = closure.chunk.bytecode[then..].ptr; + ip = closure.bytecodePtr(then); const local_count = read(u8, &ip); const capture_count = read(u8, &ip); + try vm.consumeFuel(&fuel, 1 + capture_count); const captures = allocator.alloc(Value, capture_count) catch return vm.outOfMemory(); for (captures) |*capture| { const capture_kind = read(u8, &ip); const index = read(u8, &ip); + vm.ip = ip; capture.* = switch (capture_kind) { bytecode.capture_local => (try vm.local(bottom, index)).*, bytecode.capture_capture => blk: { - try vm.validateBytecode(index < closure.captures.len, "capture index out of bounds", .{}); - break :blk closure.captures[index]; + const closureCaptures = closure.impl.bytecode.captures; + try vm.validateBytecode(index < closureCaptures.len, "capture index out of bounds", .{}); + break :blk closureCaptures[index]; }, else => .nil, }; } const new_closure = value.Closure{ - .chunk = closure.chunk, - .start = @truncate(body - closure.chunk.bytecode.ptr), + .name = name, .param_count = param_count, - .local_count = local_count, - .captures = captures, + .impl = .{ .bytecode = .{ + .chunk = closure.impl.bytecode.chunk, + .start = @truncate(body - closure.bytecodePtr(0)), + .local_count = local_count, + .captures = captures, + } }, }; const ref = allocator.create(value.Ref) catch return vm.outOfMemory(); ref.* = .{ .closure = new_closure }; try vm.push(.{ .ref = ref }); - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .jump => { - try vm.consumeFuel(&fuel, 1); const offset = read(u16, &ip); - ip = closure.chunk.bytecode[offset..].ptr; - continue :next readOpcode(&ip); + try vm.consumeFuel(&fuel, 1); + ip = closure.bytecodePtr(offset); + continue :next vm.readOpcode(&ip); }, .jump_if_not => { - try vm.consumeFuel(&fuel, 1); const offset = read(u16, &ip); + try vm.consumeFuel(&fuel, 1); const condition = try vm.pop(); if (!condition.isTruthy()) { - ip = closure.chunk.bytecode[offset..].ptr; + ip = closure.bytecodePtr(offset); } - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .field => { @@ -389,27 +456,31 @@ pub fn run( } if (found_index) |index| { - try vm.validateBytecode(index < closure.captures.len, "field index out of bounds", .{}); - const field = closure.captures[index]; + const fields = closure.impl.bytecode.captures; + try vm.validateBytecode(index < fields.len, "field index out of bounds", .{}); + const field = fields[index]; try vm.push(field); } else { return vm.throw("field with this name does not exist", .{}); } - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .call => { - try vm.consumeFuel(&fuel, 1); - const arg_count = read(u8, &ip); + try vm.consumeFuel(&fuel, 1); + const function_value = try vm.pop(); if (function_value != .ref or function_value.ref.* != .closure) { return vm.throw("attempt to call a value that is not a function", .{}); } const called_closure = &function_value.ref.closure; + // NOTE: Will need replacing for first-class system functions. + debug.assert(called_closure.impl == .bytecode); + if (arg_count != called_closure.param_count) { return vm.throw( "function expects {} arguments, but it received {}", @@ -419,7 +490,7 @@ pub fn run( const call_frame = CallFrame{ .closure = closure, - .ip = ip, + .return_addr = ip, .bottom = bottom, }; @@ -427,29 +498,61 @@ pub fn run( try vm.validateBytecode(overflow == 0, "not enough values on the stack for arguments", .{}); closure = called_closure; - ip = closure.chunk.bytecode[closure.start..].ptr; + ip = closure.bytecodePtr(0); bottom = new_bottom; - for (0..closure.local_count) |_| { + // pushCall before setting vm.closure and vm.ip, such that stack overflow errors are + // reported at the call site rather than within the called function. + try vm.pushCall(call_frame); + + // Remember to update the closure used for exception reporting, since readOpcode does + // not do that as it does with ip. + // ip also needs updating, because the topmost stack frame will assume that vm.ip is + // based in the new closure. + // We want errors for no stack space being left for locals to appear at the start of the + // function in source code, not earlier or later. Hence we do this before local slots + // are pushed onto the stack. + vm.closure = closure; + vm.ip = ip; + + for (0..closure.impl.bytecode.local_count) |_| { try vm.push(.nil); } - try vm.pushCall(call_frame); - - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, .system => { - try vm.consumeFuel(&fuel, 1); - const index = read(u8, &ip); const arg_count = read(u8, &ip); - const system_fn = system.fns[index]; + const system_fn = &system.fns[index]; - log.debug("system index={} arg_count={} system_fn={p}", .{ index, arg_count, system_fn }); + try vm.consumeFuel(&fuel, 1); - vm.storeContext(.{ .fuel = fuel }); - const result = try system_fn(.{ + log.debug("system index={} arg_count={} system_fn={s}", .{ index, arg_count, system_fn.closure.name }); + + // Push the current call frame onto the stack, such that the stack trace displays it + // in addition to the ongoing system call. + try vm.pushCall(.{ + .closure = closure, + .return_addr = ip, + .bottom = bottom, + }); + + // Also now, push the system function onto the stack, because nulling out vm.closure + // and vm.ip removes the extra "current function" stack entry. + try vm.pushCall(.{ + .closure = &system_fn.closure, + .return_addr = null, + .bottom = vm.stack_top - arg_count, + }); + + vm.storeContext(.{ + .fuel = fuel, + .closure = null, + .ip = null, + }); + const result = try system_fn.impl(.{ .vm = vm, .allocator = allocator, .args = vm.stack[vm.stack_top - arg_count .. vm.stack_top], @@ -460,7 +563,16 @@ pub fn run( vm.stack_top -= arg_count; try vm.push(result); - continue :next readOpcode(&ip); + // Restore the exception handling ip and closure to what it was. + log.debug("system restore", .{}); + vm.ip = ip; + vm.closure = closure; + + // Remove the temporary call frames. + _ = try vm.popCall(); // systemClosure + _ = try vm.popCall(); // closure + + continue :next vm.readOpcode(&ip); }, .ret => { @@ -474,26 +586,28 @@ pub fn run( "called function popped too many values. bottom={} stack_top={}", .{ bottom, vm.stack_top }, ); - try vm.validateBytecode( - call_bottom <= vm.call_stack_top, - "called function popped too many call frames. call_bottom={} call_stack_top={}", - .{ call_bottom, vm.call_stack_top }, - ); vm.stack_top = bottom; try vm.push(result); - if (vm.call_stack_top == call_bottom) { - // If this is the last call frame from this `run` instance, return from the function. - vm.storeContext(Context{ .fuel = fuel }); + if (call_frame.return_addr) |addr| { + closure = call_frame.closure; + ip = addr; + bottom = call_frame.bottom; + + vm.closure = closure; + vm.ip = ip; + } else { + // If there's nowhere to return to, stop interpreting. + vm.storeContext(Context{ + .closure = closure, + .ip = ip, + .fuel = fuel, + }); break :next; } - closure = call_frame.closure; - ip = call_frame.ip; - bottom = call_frame.bottom; - - continue :next readOpcode(&ip); + continue :next vm.readOpcode(&ip); }, _ => |invalid_opcode| { @@ -531,11 +645,14 @@ pub fn runDotter( }) catch return vm.outOfMemory(); const ref = allocator.create(value.Ref) catch return vm.outOfMemory(); ref.* = value.Ref{ .closure = .{ - .chunk = &system.record_dotter, - .start = 0, + .name = "dotter", .param_count = 1, - .local_count = 0, - .captures = data, + .impl = .{ .bytecode = .{ + .chunk = &system.record_dotter, + .start = 0, + .local_count = 0, + .captures = data, + } }, } }; const bottom = vm.stack_top; diff --git a/static/base.css b/static/base.css index 1843701..6a9ede5 100644 --- a/static/base.css +++ b/static/base.css @@ -2,6 +2,7 @@ :root { --color-text: #111; + --color-text-dim: #777; --color-error: #db344b; --color-brand-blue: #40b1f4; @@ -190,6 +191,12 @@ pre:has(code) { overflow: auto; } +/* Abbreviations */ + +abbr { + cursor: help; +} + /* Icons */ :root { diff --git a/static/brush-editor.js b/static/brush-editor.js index 41fe576..7d77433 100644 --- a/static/brush-editor.js +++ b/static/brush-editor.js @@ -1,4 +1,4 @@ -import { CodeEditor } from "rkgk/code-editor.js"; +import { CodeEditor, Selection } from "rkgk/code-editor.js"; import { SaveData } from "rkgk/framework.js"; import { builtInPresets } from "rkgk/brush-box.js"; @@ -33,6 +33,10 @@ export class BrushEditor extends HTMLElement { this.codeEditor = this.appendChild( new CodeEditor([ + { + className: "layer-syntax", + render: (code, element) => this.#renderSyntax(code, element), + }, { className: "layer-error-squiggles", render: (code, element) => this.#renderErrorSquiggles(code, element), @@ -116,8 +120,32 @@ export class BrushEditor extends HTMLElement { } else if (result.errorKind == "plain") { this.errorHeader.textContent = result.message; } else if (result.errorKind == "exception") { - // TODO: This should show a stack trace. - this.errorArea.textContent = `an exception occurred: ${result.message}`; + let renderer = new ErrorException(result); + let squiggles = renderer.prepareSquiggles(); + + this.codeEditor.rebuildLineMap(); + this.errorSquiggles = this.#computeErrorSquiggles( + this.codeEditor.lineMap, + squiggles.diagnostics, + ); + this.codeEditor.renderLayer("layer-error-squiggles"); + + renderer.addEventListener(".functionNameMouseEnter", (event) => { + squiggles.highlightSegment(event.frameIndex); + }); + + renderer.addEventListener(".functionNameMouseLeave", () => { + squiggles.highlightSegment(null); + }); + + renderer.addEventListener(".functionNameClick", (event) => { + let span = event.frame.span; + this.codeEditor.textArea.focus(); + this.codeEditor.setSelection(new Selection(span.start, span.end)); + }); + + this.errorArea.replaceChildren(); + this.errorArea.appendChild(renderer); } else { console.warn(`unknown error kind: ${result.errorKind}`); this.errorHeader.textContent = "(unknown error kind)"; @@ -206,6 +234,10 @@ export class BrushEditor extends HTMLElement { let spanElement = lineElement.appendChild(document.createElement("span")); spanElement.classList.add("squiggle", "squiggle-error"); spanElement.textContent = text; + + for (let diagnostic of segment.diagnostics) { + diagnostic.customizeSegment?.(diagnostic, spanElement); + } } } } else { @@ -213,6 +245,151 @@ export class BrushEditor extends HTMLElement { } } } + + #renderSyntax(lines, element) { + // TODO: Syntax highlighting. + // Right now we just render a layer of black text to have the text visible in the code editor. + element.textContent = lines.string; + } } customElements.define("rkgk-brush-editor", BrushEditor); + +class ErrorException extends HTMLElement { + constructor(result) { + super(); + this.result = result; + } + + prepareSquiggles() { + let diagnostics = []; + let segments = []; + + const customizeSegment = (diagnostic, segment) => { + // The control flow here is kind of spaghetti, but basically this fills out `segments` + // as the squiggles are being rendered. + // Once squiggles are rendered, you're free to call highlightSegment(i). + segments.push({ + frameIndex: diagnostic.frameIndex, + colorIndex: diagnostic.colorIndex, + segment, + }); + if (diagnostic.colorIndex != null) { + segment.classList.add("squiggle-colored"); + segment.style.setProperty("--color-index", diagnostic.colorIndex); + } + }; + + for (let i = 0; i < this.result.stackTrace.length; ++i) { + let frame = this.result.stackTrace[i]; + if (frame.span != null) { + diagnostics.push({ + start: frame.span.start, + end: frame.span.end, + + customizeSegment, + frameIndex: i, + colorIndex: i / this.result.stackTrace.length, + }); + } + } + + return { + diagnostics, + highlightSegment(frameIndex) { + let justHighlighted = new Set(); + for (let entry of segments.values()) { + let segment = entry.segment; + if (entry.frameIndex == frameIndex) { + segment.classList.add("squiggle-highlighted"); + // This logic exists so that a specific segment can display on top of + // another one if it's highlighted. + segment.style.setProperty("--highlight-color-index", entry.colorIndex); + justHighlighted.add(segment); + } else if (!justHighlighted.has(segment)) { + segment.classList.remove("squiggle-highlighted"); + } + } + }, + }; + } + + connectedCallback() { + let message = this.appendChild(document.createElement("p")); + message.classList.add("message"); + message.textContent = this.result.message; + + let stackTrace = this.appendChild(document.createElement("ol")); + stackTrace.classList.add("stack-trace"); + for (let i = 0; i < this.result.stackTrace.length; ++i) { + let frame = this.result.stackTrace[i]; + let line = stackTrace.appendChild(document.createElement("li")); + + let [_, name, lambdasString] = frame.functionName.match(/([^λ]+)(λ*)/); + + let inCaption = line.appendChild(document.createElement("span")); + inCaption.classList.add("in"); + inCaption.innerText = "in "; + + let functionName = line.appendChild(document.createElement("button")); + functionName.classList.add("function-name"); + functionName.disabled = true; + functionName.innerText = name; + if (name == "(brush)") { + functionName.classList.add("function-name-brush"); + functionName.title = "Top-level brush code"; + } + let lambdas = functionName.appendChild(document.createElement("abbr")); + lambdas.classList.add("lambdas"); + lambdas.textContent = lambdasString; + lambdas.title = + "λ - unnamed function\n" + + "Symbolizes functions that were not given a name.\n" + + "Each additional λ is one level of nesting inside another function."; + + if (frame.isSystem) { + line.append(" "); + let system = line.appendChild(document.createElement("abbr")); + system.classList.add("system"); + system.innerText = "(built-in)"; + system.title = + "This function is built into rakugaki and does not exist in your brush's source code, so you can't see it in the editor."; + } + + if (frame.span != null) { + functionName.disabled = false; + functionName.classList.add("source-link"); + functionName.style.setProperty("--color-index", i / this.result.stackTrace.length); + + functionName.addEventListener("mouseenter", () => { + this.dispatchEvent( + Object.assign(new Event(".functionNameMouseEnter"), { + frameIndex: i, + frame, + }), + ); + }); + + functionName.addEventListener("mouseleave", () => { + this.dispatchEvent( + Object.assign(new Event(".functionNameMouseLeave"), { + frameIndex: i, + frame, + }), + ); + }); + + functionName.addEventListener("click", () => { + this.dispatchEvent( + Object.assign(new Event(".functionNameClick"), { + frameIndex: i, + frame, + }), + ); + }); + } + } + } +} + +customElements.define("rkgk-brush-editor-error-exception", ErrorException); diff --git a/static/haku.js b/static/haku.js index ad9e3c5..7d4a66a 100644 --- a/static/haku.js +++ b/static/haku.js @@ -244,7 +244,7 @@ export class Haku { w2.haku2_vm_destroy(this.#pVm2); w2.haku2_defs_destroy(this.#pDefs2); w2.haku2_limits_destroy(this.#pLimits2); - w2.haku_dealloc(this.#bytecode2.ptr); + freeString2(this.#bytecode2); } setBrush(code) { @@ -332,12 +332,49 @@ export class Haku { return exn; } + #stackTrace() { + let count = w2.haku2_vm_stackframe_count(this.#pVm2); + let trace = []; + for (let i = 0; i < count; ++i) { + let isSystem = w2.haku2_vm_stackframe_is_system(this.#pVm2, i) != 0; + let pc = w2.haku2_vm_stackframe_pc(this.#pVm2, i); + + let span = null; + if (!isSystem && pc > 0) { + // NOTE: We find the span at (pc - 1), because stack frames' program counters are + // situated at where control flow _ought_ to return, and not where the function call + // is located. Therefore, to pull the program counter back into the function call, + // we subtract 1. + let pSpan = w.haku_bytecode_find_span(this.#pInstance, Math.max(0, pc - 1)); + if (pSpan != 0) { + span = { + start: w.haku_span_start(pSpan), + end: w.haku_span_end(pSpan), + }; + } + } + + trace.push({ + isSystem, + pc, + span, + functionName: readString( + memory2, + w2.haku2_vm_stackframe_function_name_len(this.#pVm2, i), + w2.haku2_vm_stackframe_function_name(this.#pVm2, i), + ), + }); + } + return trace; + } + #exceptionResult() { return { status: "error", errorKind: "exception", description: "Runtime error", message: this.#exceptionMessage(), + stackTrace: this.#stackTrace(), }; } diff --git a/static/index.css b/static/index.css index ebcb5c2..4ff0072 100644 --- a/static/index.css +++ b/static/index.css @@ -1,7 +1,7 @@ /* index.css - styles for index.html and generally main parts of the app For shared styles (such as color definitions) check out base.css. */ -* { +html { /* On the main page, we don't really want to permit selecting things. It comes off as janky-looking. */ user-select: none; @@ -412,22 +412,47 @@ rkgk-code-editor { & > .squiggle-error { text-decoration-color: var(--color-error); + + &.squiggle-colored { + --color: oklch(40% 100% calc(var(--color-index) * 300)); + text-decoration-color: var(--color); + background-color: oklch(from var(--color) 60% c h / 0.13); + + &.squiggle-highlighted { + --highlight-color-index: var(--color-index); + text-decoration: none; + background-color: oklch( + 40% 100% calc(var(--highlight-color-index) * 300) + ); + color: white; + font-weight: bold; + } + } } } } + & > .layer-syntax { + white-space: pre-wrap; + } + & > textarea { display: block; width: calc(100% - var(--gutter-width)); + margin: 0; margin-left: var(--gutter-width); padding: 0; - box-sizing: border-box; + border: none; + overflow: hidden; resize: none; + white-space: pre-wrap; - border: none; + background: none; + color: transparent; + caret-color: var(--color-text); &:focus { /* The outline is displayed on the parent element to also surround the gutter. */ @@ -490,6 +515,67 @@ rkgk-brush-editor.rkgk-panel { margin: 0; color: var(--color-error); white-space: pre-wrap; + + user-select: text; + + max-height: 20em; + overflow-y: auto; + } +} + +rkgk-brush-editor-error-exception { + & > .message { + margin: 4px 0; + } + + & > .stack-trace { + margin: 0; + margin-left: 4ch; + + color: var(--color-text); + + & > li { + &::marker { + color: var(--color-text-dim); + } + + & > button.function-name { + border: none; + border-radius: 0; + padding: 0; + background: none; + + user-select: text; + + font-weight: bold; + + &:not(:disabled) { + text-decoration: underline; + cursor: pointer; + } + + &:disabled { + opacity: 100%; + } + + &.source-link { + --color-index: 0; /* set by JavaScript */ + --color: oklch(40% 100% calc(var(--color-index) * 300)); + + color: var(--color); + + &:hover { + text-decoration: none; + background-color: var(--color); + color: white; + } + } + } + + & > .system { + color: var(--color-text-dim); + } + } } }