diff --git a/src/AstGen.zig b/src/AstGen.zig index a137046..a516b67 100644 --- a/src/AstGen.zig +++ b/src/AstGen.zig @@ -1134,12 +1134,13 @@ fn assign(gi: *GenIr, scope: *Scope, node: *const Ast.Node) InnerError!void { const expr_node = node.data.bin.rhs.?; const name_str = try astgen.strFromNode(identifier_node); - if (scope.lookup(name_str.index)) |decl| { - const expr_result = try expr(gi, scope, expr_node); - _ = try gi.addBinaryNode(.store, decl.inst_index.toRef(), expr_result); - return; - } - _ = try gi.addStrTok(.decl_ref, name_str.index, node.loc.start); + const lhs = if (scope.lookup(name_str.index)) |decl| blk: { + break :blk decl.inst_index.toRef(); + } else blk: { + break :blk try gi.addStrTok(.decl_ref, name_str.index, node.loc.start); + }; + const rhs = try expr(gi, scope, expr_node); + _ = try gi.addBinaryNode(.store, lhs, rhs); } fn assignOp( diff --git a/src/Story.zig b/src/Story.zig index a865996..86fc49b 100644 --- a/src/Story.zig +++ b/src/Story.zig @@ -24,6 +24,8 @@ choice_selected: ?Choice = null, output_buffer: std.ArrayListUnmanaged(OutputCommand) = .empty, output_scratch: std.ArrayListUnmanaged(u8) = .empty, current_choices: std.ArrayListUnmanaged(Choice) = .empty, +variable_observers: std.StringHashMapUnmanaged(VariableObserver) = .empty, + globals: std.StringHashMapUnmanaged(Value) = .empty, stack: []Value = &.{}, call_stack: []CallFrame = &.{}, @@ -38,6 +40,17 @@ dump_writer: ?*std.Io.Writer = null, pub const default_knot_name: [:0]const u8 = "$__main__$"; +pub const VariableObserver = struct { + callback: Callback, + context: Context, + + pub const Callback = *const fn (Value, Context) anyerror!void; + + pub const Context = struct { + ptr: *anyopaque, + }; +}; + pub const Opcode = enum(u8) { /// Exit the VM normally. exit, @@ -337,6 +350,7 @@ pub fn deinit(story: *Story) void { story.arena.deinit(); story.globals.deinit(gpa); + story.variable_observers.deinit(gpa); story.current_choices.deinit(gpa); story.output_scratch.deinit(gpa); story.output_buffer.deinit(gpa); @@ -491,7 +505,7 @@ const StepSignal = union(enum) { choices_ready, }; -fn step(vm: *Story) !StepSignal { +fn step(vm: *Story, variables: *VariablesState) !StepSignal { assert(vm.call_stack_top > 0); assert(vm.can_advance == true); @@ -695,12 +709,13 @@ fn step(vm: *Story) !StepSignal { }, .store_global => { const arg_offset: u8 = @intFromEnum(code[frame.ip + 1]); - const global_name = try vm.getConstant(frame, arg_offset); + const global_name = try getConstant(vm, frame, arg_offset); - if (vm.peekStack(0)) |arg| { - try vm.setGlobal(global_name, arg); - _ = vm.popStack(); - try vm.pushStack(arg); + if (peekStack(vm, 0)) |arg| { + try setGlobal(vm, global_name, arg); + _ = popStack(vm); + try pushStack(vm, arg); + try variables.update(gpa, global_name); } else { return error.InvalidArgument; } @@ -799,6 +814,27 @@ fn resolveOutputStream( return result.toOwnedSlice(gpa); } +const VariablesState = struct { + changed: std.StringHashMapUnmanaged(void) = .empty, + + pub fn update( + state: *VariablesState, + gpa: std.mem.Allocator, + value: Value, + ) !void { + switch (value) { + .object => |object| switch (object.tag) { + .string => { + const str_object: *Object.String = @ptrCast(object); + try state.changed.put(gpa, str_object.toSlice(), undefined); + }, + else => unreachable, + }, + else => unreachable, + } + } +}; + pub fn advance(story: *Story) !?[]const u8 { const arena = story.arena.allocator(); const output_buffer = &story.output_buffer; @@ -808,17 +844,26 @@ pub fn advance(story: *Story) !?[]const u8 { output_scratch.clearRetainingCapacity(); if (!story.can_advance) return null; - while (story.can_advance) { - const signal = try story.step(); - switch (signal) { - .exit => { - story.is_exited = true; - break; - }, - .done, .choices_ready => break, + defer story.can_advance = false; + + var variables_state: VariablesState = .{}; + defer variables_state.changed.deinit(story.gpa); + + const signal = try story.step(&variables_state); + switch (signal) { + .exit => story.is_exited = true, + .done, .choices_ready => {}, + } + + var state_iter = variables_state.changed.keyIterator(); + while (state_iter.next()) |key| { + if (story.variable_observers.get(key.*)) |observer| { + // If there's an observer, we can assume that a global exists. + const value = story.globals.get(key.*).?; + try observer.callback(value, observer.context); } } - story.can_advance = false; + if (output_buffer.items.len == 0) return null; return try resolveOutputStream(story, arena, output_buffer.items[0..]); } @@ -835,6 +880,21 @@ pub fn selectChoiceIndex(story: *Story, index: usize) !void { _ = story.arena.reset(.retain_capacity); } +pub fn observeVariable( + story: *Story, + name: []const u8, + callback: VariableObserver.Callback, + context: VariableObserver.Context, +) !void { + const gpa = story.gpa; + if (story.globals.get(name) == null) return error.VariableNotFound; + + try story.variable_observers.put(gpa, name, .{ + .callback = callback, + .context = context, + }); +} + pub fn dump(story: *Story, writer: *std.Io.Writer) !void { return Dumper.dump(story, writer); } diff --git a/src/Story/runtime_tests.zig b/src/Story/runtime_tests.zig index 117bf00..252d390 100644 --- a/src/Story/runtime_tests.zig +++ b/src/Story/runtime_tests.zig @@ -1,6 +1,7 @@ const std = @import("std"); const fatal = std.process.fatal; const ink = @import("../root.zig"); +const Story = ink.Story; test "fixture - variable arithmetic" { try testRuntimeFixture("variable-arithmetic"); @@ -256,6 +257,37 @@ test "fixture - I135 (Bools can be coerced)" { try testRuntimeFixture("I135"); } +test "variable observer" { + const gpa = std.testing.allocator; + var io_w = std.Io.Writer.Allocating.init(gpa); + defer io_w.deinit(); + + var story = try ink.Story.fromSourceBytes(gpa, + \\VAR foo = 1 + \\~foo = 10 + , .{ + .filename = "", + .error_writer = &io_w.writer, + }); + defer story.deinit(); + + const TestState = struct { score: i64 }; + var test_state = TestState{ .score = 0 }; + + try story.observeVariable("foo", struct { + fn observe( + value: Story.Value, + ctx: Story.VariableObserver.Context, + ) anyerror!void { + const local_test_state: *TestState = @ptrCast(@alignCast(ctx.ptr)); + local_test_state.score += value.int; + } + }.observe, .{ .ptr = &test_state }); + + _ = try story.advance(); + return std.testing.expectEqual(10, test_state.score); +} + const Options = struct { input_reader: *std.Io.Reader, error_writer: *std.Io.Writer, @@ -265,7 +297,7 @@ const Options = struct { fn testRunner(gpa: std.mem.Allocator, source_bytes: [:0]const u8, options: Options) !void { const io_r = options.input_reader; const io_w = options.transcript_writer; - var story = try ink.Story.fromSourceBytes(gpa, source_bytes, .{ + var story = try Story.fromSourceBytes(gpa, source_bytes, .{ .filename = "", .error_writer = options.error_writer, });