From ee26be6254c11906f7842535a4cc355db85339f5 Mon Sep 17 00:00:00 2001 From: Brett Broadhurst Date: Mon, 16 Mar 2026 17:33:31 -0600 Subject: [PATCH] feat: code generation for simple choice statements, testing machinery --- src/AstGen.zig | 120 +++++++++++----------- src/Ir.zig | 44 ++++++++ src/Sema.zig | 84 +++++++++++++++ src/Story.zig | 24 +++-- src/main.zig | 12 ++- src/root.zig | 1 + src/runtime_tests.zig | 73 +++++++++++++ src/testdata/hello-world/input.txt | 0 src/testdata/hello-world/story.ink | 1 + src/testdata/hello-world/transcript.txt | 1 + src/testdata/monsieur-fogg/input.txt | 1 + src/testdata/monsieur-fogg/story.ink | 7 ++ src/testdata/monsieur-fogg/transcript.txt | 6 ++ 13 files changed, 304 insertions(+), 70 deletions(-) create mode 100644 src/runtime_tests.zig create mode 100644 src/testdata/hello-world/input.txt create mode 100644 src/testdata/hello-world/story.ink create mode 100644 src/testdata/hello-world/transcript.txt create mode 100644 src/testdata/monsieur-fogg/input.txt create mode 100644 src/testdata/monsieur-fogg/story.ink create mode 100644 src/testdata/monsieur-fogg/transcript.txt diff --git a/src/AstGen.zig b/src/AstGen.zig index 0d42f8d..0be2315 100644 --- a/src/AstGen.zig +++ b/src/AstGen.zig @@ -758,6 +758,7 @@ fn switchStmt( try case_indexes.ensureUnusedCapacity(gpa, switch_stmt.cases.len); defer case_indexes.deinit(gpa); + // TODO: Length checks. const switch_cases = switch_stmt.cases[0 .. switch_stmt.cases.len - 1]; for (switch_cases) |case_stmt| { // TODO: Maybe make this non-nullable @@ -855,78 +856,77 @@ fn assignStmt(gi: *GenIr, scope: *Scope, node: *const Ast.Node) InnerError!void return gi.fail(.unknown_identifier, identifier_node); } -fn choiceStmt(gen: *GenIr, scope: *Scope, stmt_node: *const Ast.Node) InnerError!void { - const Choice = struct { - label_index: usize, - start_expression: ?*const Ast.Node, - option_expression: ?*const Ast.Node, - inner_expression: ?*const Ast.Node, - block_stmt: ?*const Ast.Node, - }; +fn choiceStarStmt(gi: *GenIr, _: *Scope, node: *const Ast.Node) InnerError!Ir.Inst.Ref { + return stringLiteral(gi, node); +} - const branch_list = stmt_node.data.list.items orelse unreachable; - assert(branch_list.len != 0); +fn choiceStmt( + parent_block: *GenIr, + scope: *Scope, + stmt_node: *const Ast.Node, +) InnerError!void { + const astgen = parent_block.astgen; + const gpa = astgen.gpa; + const choice_branches = stmt_node.data.list.items.?; + assert(choice_branches.len != 0); - const gpa = gen.astgen.gpa; - var choice_list: std.ArrayListUnmanaged(Choice) = .empty; - defer choice_list.deinit(gpa); - try choice_list.ensureUnusedCapacity(gpa, branch_list.len); + const choice_br = try parent_block.makeBlockInst(.choice_br); + var case_indexes: std.ArrayListUnmanaged(u32) = .empty; + try case_indexes.ensureUnusedCapacity(gpa, choice_branches.len); + defer case_indexes.deinit(gpa); - for (branch_list) |branch_stmt| { + for (choice_branches) |branch_stmt| { assert(branch_stmt.tag == .choice_star_stmt or branch_stmt.tag == .choice_plus_stmt); const branch_data = branch_stmt.data.bin; - const branch_expr = branch_data.lhs orelse unreachable; - const branch_expr_data = branch_expr.data.choice_expr; - const label_index = try gen.makeLabel(); + const branch_expr = branch_data.lhs.?.data.choice_expr; + var op_1: Ir.Inst.Ref = .none; + var op_2: Ir.Inst.Ref = .none; + var op_3: Ir.Inst.Ref = .none; - if (branch_expr_data.start_expr) |node| { - try stringLiteral(gen, node); - try gen.emitSimpleInst(.stream_push); + if (branch_expr.start_expr) |node| { + op_1 = try choiceStarStmt(parent_block, scope, node); } - if (branch_expr_data.option_expr) |node| { - try stringLiteral(gen, node); - try gen.emitSimpleInst(.stream_push); + if (branch_expr.option_expr) |node| { + op_2 = try choiceStarStmt(parent_block, scope, node); + } + if (branch_expr.inner_expr) |node| { + op_3 = try choiceStarStmt(parent_block, scope, node); } - const fixup_offset = try gen.emitJumpInst(.br_push); - _ = try gen.makeFixup(.{ - .mode = .absolute, - .label_index = label_index, - .code_offset = fixup_offset, - }); + var sub_block = parent_block.makeSubBlock(); + defer sub_block.unstack(); + if (branch_data.rhs) |branch_body| { + _ = try blockStmt(&sub_block, scope, branch_body); + } + _ = try sub_block.addUnaryNode(.implicit_ret, .none); - choice_list.appendAssumeCapacity(.{ - .label_index = label_index, - .start_expression = branch_expr_data.start_expr, - .inner_expression = branch_expr_data.inner_expr, - .option_expression = branch_expr_data.option_expr, - .block_stmt = branch_data.rhs, - }); + const body = sub_block.instructionsSlice(); + const case_extra_len = @typeInfo(Ir.Inst.SwitchBr.Case).@"struct".fields.len + body.len; + try astgen.extra.ensureUnusedCapacity(gpa, case_extra_len); + const extra_index = astgen.addExtraAssumeCapacity( + Ir.Inst.ChoiceBr.Case{ + .operand_1 = op_1, + .operand_2 = op_2, + .operand_3 = op_3, + .body_len = @intCast(body.len), + }, + ); + astgen.appendBlockBody(body); + case_indexes.appendAssumeCapacity(extra_index); } - try gen.emitSimpleInst(.br_table); - try gen.emitSimpleInst(.br_select_index); - try gen.emitSimpleInst(.br_dispatch); + try parent_block.instructions.append(gpa, choice_br); + const extra_len = @typeInfo(Ir.Inst.ChoiceBr).@"struct".fields.len + case_indexes.items.len; + try astgen.extra.ensureUnusedCapacity(gpa, extra_len); - for (choice_list.items) |choice| { - gen.setLabel(choice.label_index); - - if (choice.start_expression) |expr_node| { - try stringLiteral(gen, expr_node); - try gen.emitSimpleInst(.stream_push); - } - if (choice.inner_expression) |expr_node| { - try stringLiteral(gen, expr_node); - try gen.emitSimpleInst(.stream_push); - } - - try gen.emitSimpleInst(.stream_flush); - if (choice.block_stmt) |block| { - try blockStmt(gen, scope, block); - } else { - try gen.emitSimpleInst(.exit); - } - } + astgen.instructions.items[@intFromEnum(choice_br)].data.payload = .{ + .payload_index = astgen.addExtraAssumeCapacity( + Ir.Inst.ChoiceBr{ + .cases_len = @intCast(choice_branches.len), + }, + ), + }; + astgen.extra.appendSliceAssumeCapacity(case_indexes.items[0..]); } fn tempDecl(gi: *GenIr, scope: *Scope, decl_node: *const Ast.Node) !void { @@ -982,7 +982,7 @@ fn blockInner(gi: *GenIr, parent_scope: *Scope, stmt_list: []*Ast.Node) !void { .temp_decl => try tempDecl(gi, &child_scope, inner_node), .assign_stmt => try assignStmt(gi, &child_scope, inner_node), .content_stmt => try contentStmt(gi, &child_scope, inner_node), - //.choice_stmt => try choiceStmt(gen, scope, inner_node), + .choice_stmt => try choiceStmt(gi, &child_scope, inner_node), .expr_stmt => try exprStmt(gi, &child_scope, inner_node), else => unreachable, }; diff --git a/src/Ir.zig b/src/Ir.zig index 4c39e0c..e46be51 100644 --- a/src/Ir.zig +++ b/src/Ir.zig @@ -75,6 +75,8 @@ pub const Inst = struct { switch_br, content_push, content_flush, + choice_br, + implicit_ret, }; pub const Data = union { @@ -138,6 +140,17 @@ pub const Inst = struct { body_len: u32, }; }; + + pub const ChoiceBr = struct { + cases_len: u32, + + pub const Case = struct { + operand_1: Ref, + operand_2: Ref, + operand_3: Ref, + body_len: u32, + }; + }; }; pub const Global = struct { @@ -326,6 +339,35 @@ const Render = struct { try io_w.writeAll(")"); } + fn renderChoiceBr(r: *Render, ir: Ir, inst: Inst) Error!void { + const io_w = r.writer; + const data = inst.data.payload; + const choice_extra = ir.extraData(Inst.ChoiceBr, data.payload_index); + const options_slice = ir.bodySlice(choice_extra.end, choice_extra.data.cases_len); + + try io_w.print("{s}(\n", .{@tagName(inst.tag)}); + + for (options_slice) |option_index| { + const case_extra = ir.extraData(Inst.ChoiceBr.Case, @intFromEnum(option_index)); + const body_slice = ir.bodySlice(case_extra.end, case_extra.data.body_len); + const old_len = try r.prefix.pushChildPrefix(r.gpa); + defer r.prefix.restore(old_len); + + try r.prefix.writeIndent(io_w); + try renderInstRef(r, case_extra.data.operand_1); + try io_w.writeAll(", "); + try renderInstRef(r, case_extra.data.operand_2); + try io_w.writeAll(", "); + try renderInstRef(r, case_extra.data.operand_3); + + try io_w.print(" = ", .{}); + try renderBodyInner(r, ir, body_slice); + try io_w.writeAll(",\n"); + } + try r.prefix.writeIndent(io_w); + try io_w.writeAll(")"); + } + fn renderKnotDecl(r: *Render, ir: Ir, inst: Inst) Error!void { const io_w = r.writer; const extra = ir.extraData(Inst.Knot, inst.data.payload.payload_index); @@ -417,6 +459,8 @@ const Render = struct { }, .content_push => try r.renderUnary(inst), .content_flush => try r.renderUnary(inst), + .choice_br => try r.renderChoiceBr(ir, inst), + .implicit_ret => try r.renderUnary(inst), } try io_w.writeAll("\n"); } diff --git a/src/Sema.zig b/src/Sema.zig index 4e8d1f0..2cea5ac 100644 --- a/src/Sema.zig +++ b/src/Sema.zig @@ -238,6 +238,76 @@ fn irContentFlush(_: *Sema, chunk: *Chunk, _: Ir.Inst.Index) InnerError!Ref { return chunk.addByteOp(.stream_flush); } +fn irChoiceBr(sema: *Sema, chunk: *Chunk, inst: Ir.Inst.Index) InnerError!void { + const data = sema.ir.instructions[@intFromEnum(inst)].data.payload; + const choice_extra = sema.ir.extraData(Ir.Inst.ChoiceBr, data.payload_index); + const options_slice = sema.ir.bodySlice(choice_extra.end, choice_extra.data.cases_len); + + var branch_labels: std.ArrayListUnmanaged(usize) = .empty; + try branch_labels.ensureUnusedCapacity(sema.gpa, options_slice.len + 1); + defer branch_labels.deinit(sema.gpa); + + for (options_slice) |option_index| { + const case_extra = sema.ir.extraData(Ir.Inst.ChoiceBr.Case, @intFromEnum(option_index)); + const case_label = try chunk.addLabel(); + branch_labels.appendAssumeCapacity(case_label); + + switch (case_extra.data.operand_1) { + .none => {}, + else => |content| { + const content_inst = chunk.resolveInst(content); + _ = try chunk.doLoad(content_inst); + _ = try chunk.addByteOp(.stream_push); + }, + } + switch (case_extra.data.operand_2) { + .none => {}, + else => |content| { + const content_inst = chunk.resolveInst(content); + _ = try chunk.doLoad(content_inst); + _ = try chunk.addByteOp(.stream_push); + }, + } + + try chunk.addFixupAbsolute(.br_push, case_label); + } + + _ = try chunk.addByteOp(.br_table); + _ = try chunk.addByteOp(.br_select_index); + _ = try chunk.addByteOp(.br_dispatch); + + for (options_slice, branch_labels.items) |option_index, label| { + const case_extra = sema.ir.extraData(Ir.Inst.ChoiceBr.Case, @intFromEnum(option_index)); + const body_slice = sema.ir.bodySlice(case_extra.end, case_extra.data.body_len); + + chunk.setLabel(label); + + switch (case_extra.data.operand_1) { + .none => {}, + else => |content| { + const content_inst = chunk.resolveInst(content); + _ = try chunk.doLoad(content_inst); + _ = try chunk.addByteOp(.stream_push); + }, + } + switch (case_extra.data.operand_3) { + .none => {}, + else => |content| { + const content_inst = chunk.resolveInst(content); + _ = try chunk.doLoad(content_inst); + _ = try chunk.addByteOp(.stream_push); + }, + } + _ = try chunk.addByteOp(.stream_flush); + + try blockBodyInner(sema, chunk, body_slice); + } +} + +fn irImplicitRet(_: *Sema, chunk: *Chunk, _: Ir.Inst.Index) InnerError!Ref { + return chunk.addByteOp(.exit); +} + fn irDeclRef(sema: *Sema, _: *Chunk, inst: Ir.Inst.Index) InnerError!Ref { const data = sema.ir.instructions[@intFromEnum(inst)].data.string; return sema.getGlobal(data.start); @@ -358,6 +428,11 @@ fn blockBodyInner(sema: *Sema, chunk: *Chunk, body: []const Ir.Inst.Index) Inner }, .content_push => try irContentPush(sema, chunk, inst), .content_flush => try irContentFlush(sema, chunk, inst), + .choice_br => { + try irChoiceBr(sema, chunk, inst); + continue; + }, + .implicit_ret => try irImplicitRet(sema, chunk, inst), }; try chunk.inst_map.put(gpa, inst, ref); } @@ -446,6 +521,15 @@ const Chunk = struct { }); } + fn addFixupAbsolute(chunk: *Chunk, op: Story.Opcode, label: usize) !void { + const code_ref = try chunk.addJumpOp(op); + return chunk.fixups.append(chunk.sema.gpa, .{ + .mode = .absolute, + .label_index = @intCast(label), + .code_offset = code_ref.index, + }); + } + fn addLabel(chunk: *Chunk) error{OutOfMemory}!usize { const label_index = chunk.labels.items.len; try chunk.labels.append(chunk.sema.gpa, .{ diff --git a/src/Story.zig b/src/Story.zig index b91d8c1..5a6e12f 100644 --- a/src/Story.zig +++ b/src/Story.zig @@ -490,6 +490,8 @@ pub const LoadOptions = struct { dump_writer: ?*std.Io.Writer = null, stderr_writer: *std.Io.Writer, use_color: bool = true, + dump_ast: bool = false, + dump_ir: bool = false, }; pub fn selectChoiceIndex(story: *Story, index: usize) !void { @@ -508,11 +510,13 @@ pub fn loadFromString( const arena = arena_allocator.allocator(); const ast = try Ast.parse(gpa, arena, source_bytes, "", 0); - if (options.dump_writer) |w| { - try w.writeAll("=== AST ===\n"); - try ast.render(gpa, w, .{ - .use_color = options.use_color, - }); + if (options.dump_ast) { + if (options.dump_writer) |w| { + try w.writeAll("=== AST ===\n"); + try ast.render(gpa, w, .{ + .use_color = options.use_color, + }); + } } if (ast.errors.len > 0) { try ast.renderErrors(gpa, options.stderr_writer, .{ @@ -532,10 +536,12 @@ pub fn loadFromString( return error.CompilationFailed; } - if (options.dump_writer) |w| { - try w.writeAll("=== Semantic IR ===\n"); - try sem_ir.dumpInfo(w); - try sem_ir.render(gpa, w); + if (options.dump_ir) { + if (options.dump_writer) |w| { + try w.writeAll("=== Semantic IR ===\n"); + try sem_ir.dumpInfo(w); + try sem_ir.render(gpa, w); + } } var compiled = try Sema.compile(gpa, &sem_ir); diff --git a/src/main.zig b/src/main.zig index 0017a9b..53ba5c3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -34,6 +34,8 @@ fn mainArgs( var arg_index: usize = 1; var compile_only: bool = false; var dump_ast: bool = false; + var dump_ir: bool = false; + var dump_story: bool = false; var use_stdin: bool = false; var use_color: bool = false; @@ -46,6 +48,10 @@ fn mainArgs( compile_only = true; } else if (std.mem.eql(u8, arg, "--dump-ast")) { dump_ast = true; + } else if (std.mem.eql(u8, arg, "--dump-ir")) { + dump_ir = true; + } else if (std.mem.eql(u8, arg, "--dump-story")) { + dump_story = true; } else if (std.mem.eql(u8, arg, "--use-color")) { use_color = true; } else { @@ -86,10 +92,14 @@ fn mainArgs( .stderr_writer = &stderr_writer.interface, .dump_writer = &stdout_writer.interface, .use_color = use_color, + .dump_ast = dump_ast, + .dump_ir = dump_ir, }); defer story.deinit(); - try story.dump(&stderr_writer.interface); + if (dump_story) { + try story.dump(&stderr_writer.interface); + } if (compile_only) return; while (!story.is_exited and story.can_advance) { diff --git a/src/root.zig b/src/root.zig index 5567e53..6864bc1 100644 --- a/src/root.zig +++ b/src/root.zig @@ -6,4 +6,5 @@ test { _ = tokenizer; _ = Ast; _ = Story; + _ = @import("runtime_tests.zig"); } diff --git a/src/runtime_tests.zig b/src/runtime_tests.zig new file mode 100644 index 0000000..07c28ba --- /dev/null +++ b/src/runtime_tests.zig @@ -0,0 +1,73 @@ +const std = @import("std"); +const fatal = std.process.fatal; +const ink = @import("root.zig"); + +const Options = struct { + input_reader: *std.Io.Reader, + error_writer: *std.Io.Writer, + transcript_writer: *std.Io.Writer, +}; + +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.loadFromString(gpa, source_bytes, .{ + .stderr_writer = options.error_writer, + }); + defer story.deinit(); + + while (!story.is_exited and story.can_advance) { + while (story.can_advance) { + const content_text = try story.advance(gpa); + defer gpa.free(content_text); + if (content_text.len != 0) { + try io_w.print("{s}\n", .{content_text}); + } + } + if (story.current_choices.items.len > 0) { + for (story.current_choices.items, 0..) |*choice, index| { + const choice_text = try choice.text.toOwnedSlice(gpa); + defer gpa.free(choice_text); + try io_w.print("{d}: {s}\n", .{ index + 1, choice_text }); + } + try io_w.print("?> ", .{}); + + const input_line = try io_r.takeDelimiter('\n'); + if (input_line) |bytes| { + const choice_index = try std.fmt.parseUnsigned(usize, bytes, 10); + try story.selectChoiceIndex(if (choice_index == 0) 0 else choice_index - 1); + } + } + } + return io_w.flush(); +} + +fn testRuntimeFixture(comptime fixture: []const u8) !void { + const test_root = "testdata/"; + const source_bytes = @embedFile(test_root ++ fixture ++ "/story.ink"); + const transcript_bytes = @embedFile(test_root ++ fixture ++ "/transcript.txt"); + const input_bytes = @embedFile(test_root ++ fixture ++ "/input.txt"); + var stderr_buffer: [1024]u8 = undefined; + const gpa = std.testing.allocator; + const stderr = std.fs.File.stderr(); + + var io_r = std.Io.Reader.fixed(input_bytes); + var io_w = std.Io.Writer.Allocating.init(gpa); + defer io_w.deinit(); + var stderr_writer = stderr.writer(&stderr_buffer); + + try testRunner(gpa, source_bytes, .{ + .input_reader = &io_r, + .error_writer = &stderr_writer.interface, + .transcript_writer = &io_w.writer, + }); + return std.testing.expectEqualSlices(u8, transcript_bytes, io_w.written()); +} + +test "fixture - hello world" { + try testRuntimeFixture("hello-world"); +} + +test "fixture - monsieur-fogg" { + try testRuntimeFixture("monsieur-fogg"); +} diff --git a/src/testdata/hello-world/input.txt b/src/testdata/hello-world/input.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/testdata/hello-world/story.ink b/src/testdata/hello-world/story.ink new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/src/testdata/hello-world/story.ink @@ -0,0 +1 @@ +Hello, world! diff --git a/src/testdata/hello-world/transcript.txt b/src/testdata/hello-world/transcript.txt new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/src/testdata/hello-world/transcript.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/src/testdata/monsieur-fogg/input.txt b/src/testdata/monsieur-fogg/input.txt new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/src/testdata/monsieur-fogg/input.txt @@ -0,0 +1 @@ +3 diff --git a/src/testdata/monsieur-fogg/story.ink b/src/testdata/monsieur-fogg/story.ink new file mode 100644 index 0000000..e57d1b2 --- /dev/null +++ b/src/testdata/monsieur-fogg/story.ink @@ -0,0 +1,7 @@ +"What's that?" my master asked. +* "I am somewhat tired[."]," I repeated. + "Really," he responded. "How deleterious." +* "Nothing, Monsieur!"[] I replied. + "Very good, then." +* "I said, this journey is appalling[."] and I want no more of it." + "Ah," he replied, not unkindly. "I see you are feeling frustrated. Tomorrow, things will improve." diff --git a/src/testdata/monsieur-fogg/transcript.txt b/src/testdata/monsieur-fogg/transcript.txt new file mode 100644 index 0000000..6513826 --- /dev/null +++ b/src/testdata/monsieur-fogg/transcript.txt @@ -0,0 +1,6 @@ +"What's that?" my master asked. +1: "I am somewhat tired." +2: "Nothing, Monsieur!" +3: "I said, this journey is appalling." +?> "I said, this journey is appalling and I want no more of it." +"Ah," he replied, not unkindly. "I see you are feeling frustrated. Tomorrow, things will improve."