const std = @import("std"); const modal = @import("modal.zig"); // tutor_gen: a separate binary (NOT comptime) that parses tutor.txt and writes // tutor_cases.zig — a file of standard Zig `test` blocks, one per practice block. // `zig build tutor-gen` runs it (writing the file); `zig build unit-test` then // compiles+runs those tests against modal.stepFile. // // Format of tutor.txt practice blocks (all directive lines start with "# "): // # keys: l l l i X esc <- space-separated keystroke tokens // # at: 0,4 <- optional starting cursor "row,col" (default 0,0) // # before <- the lines below are the starting buffer // // # after <- the lines below are the expected buffer // // Everything not under a # before/# after is prose (ignored). A `# keys:` starts // a new case; cases end at the next `# keys:` or a non-practice line. // // Keystroke tokens: each char of a token is typed in turn, EXCEPT the named // specials `esc`, `enter`, `bs` (backspace), `left/right/up/down`, `home/end`, // `pgup/pgdn`. `Ctrl-d`/`Ctrl-u`/`Ctrl-f`/`Ctrl-b` map to half/full page. const Case = struct { keys: []const u8, at: ?[]const u8 = null, before: []const u8, after: []const u8, }; fn parseCases(alloc: std.mem.Allocator, src: []const u8) ![]Case { var cases: std.ArrayList(Case) = .empty; var it = std.mem.splitScalar(u8, src, '\n'); // accumulate the current case var cur_keys: ?[]const u8 = null; var cur_at: ?[]const u8 = null; var before: ?std.ArrayList(u8) = null; var after: ?std.ArrayList(u8) = null; // which section is actively collecting content (so # after stops before from // absorbing the after lines). `none` = neither. var section: enum { none, before, after } = .none; defer if (before) |*b| b.deinit(alloc); defer if (after) |*a| a.deinit(alloc); // NOTE: cur_keys / cur_at ownership is handled explicitly at each flush/drop // (moved into a case, or freed + nulled). We do NOT defer-free them here: // a defer would double-free a value that was moved into a case (the move // leaves the local pointing at the now-case-owned memory until it's nulled, // and a function-exit defer can't tell). while (it.next()) |raw_line| { // Practice-block lines in tutor.txt are indented by one leading space // (the tutor's prose style). Strip exactly that one space so directives // match AND content lines don't carry the indent into the buffer. var line = raw_line; if (line.len > 0 and line[0] == ' ') line = line[1..]; line = std.mem.trimEnd(u8, line, "\r"); if (std.mem.startsWith(u8, line, "# keys:")) { // flush previous case if (cur_keys != null and before != null and after != null) { try cases.append(alloc, .{ .keys = cur_keys.?, .at = cur_at, .before = try before.?.toOwnedSlice(alloc), .after = try after.?.toOwnedSlice(alloc), }); before = null; after = null; } else if (cur_keys != null) { // a # keys: with no before/after — drop it (incomplete): free the // owned keys/at so they don't leak. if (cur_keys) |k| alloc.free(k); if (cur_at) |at| alloc.free(at); before = null; after = null; } cur_keys = try alloc.dupe(u8, std.mem.trim(u8, line["# keys:".len..], " ")); // cur_at from a prior case was either moved (flushed) or freed (drop); // start fresh. cur_at = null; before = null; after = null; section = .none; continue; } if (std.mem.startsWith(u8, line, "# at:")) { if (cur_at) |old| alloc.free(old); cur_at = try alloc.dupe(u8, std.mem.trim(u8, line["# at:".len..], " ")); continue; } if (std.mem.startsWith(u8, line, "# before")) { before = .empty; after = null; section = .before; continue; } if (std.mem.startsWith(u8, line, "# after")) { after = .empty; section = .after; continue; } // any other "# ..." directive that isn't keys/at/before/after ends a case if (std.mem.startsWith(u8, line, "#")) { if (cur_keys != null and before != null and after != null and after.?.items.len > 0) { try cases.append(alloc, .{ .keys = cur_keys.?, .at = cur_at, .before = try before.?.toOwnedSlice(alloc), .after = try after.?.toOwnedSlice(alloc), }); } else { // incomplete: free the owned keys/at + any partial content if (cur_keys) |k| alloc.free(k); if (cur_at) |at| alloc.free(at); if (before) |*b| b.deinit(alloc); if (after) |*a| a.deinit(alloc); } cur_keys = null; cur_at = null; before = null; after = null; section = .none; continue; } // a blank line: if we have a complete case (keys + before + after with // content), flush it now — the practice block is delimited by blank lines // in the tutor, so prose after the blank line must NOT be absorbed. if (line.len == 0) { if (cur_keys != null and before != null and after != null and after.?.items.len > 0) { try cases.append(alloc, .{ .keys = cur_keys.?, .at = cur_at, .before = try before.?.toOwnedSlice(alloc), .after = try after.?.toOwnedSlice(alloc), }); cur_keys = null; before = null; after = null; section = .none; } else { section = .none; // a blank line closes the active section } continue; } // a content line: belongs to the ACTIVE section only if (section == .before) { if (before) |*b| { if (b.items.len > 0) try b.append(alloc, '\n'); try b.appendSlice(alloc, line); } continue; } if (section == .after) { if (after) |*a| { if (a.items.len > 0) try a.append(alloc, '\n'); try a.appendSlice(alloc, line); } continue; } // prose line, ignore } // flush trailing case if (cur_keys != null and before != null and after != null and after.?.items.len > 0) { try cases.append(alloc, .{ .keys = cur_keys.?, .at = cur_at, .before = try before.?.toOwnedSlice(alloc), .after = try after.?.toOwnedSlice(alloc), }); // ownership transferred; clear so the defers above don't double-free. cur_keys = null; cur_at = null; before = null; after = null; } return cases.toOwnedSlice(alloc); } // Emit a Zig string literal for `s`, escaping per Zig string syntax. fn emitStringLiteral(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: []const u8) !void { try out.append(alloc, '"'); for (s) |c| { switch (c) { '"' => try out.appendSlice(alloc, "\\\""), '\\' => try out.appendSlice(alloc, "\\\\"), '\n' => try out.appendSlice(alloc, "\\n"), '\r' => try out.appendSlice(alloc, "\\r"), '\t' => try out.appendSlice(alloc, "\\t"), else => { if (c >= 0x20 and c < 0x7f) { try out.append(alloc, c); } else { try out.print(alloc, "\\x{x:0>2}", .{c}); } }, } } try out.append(alloc, '"'); } fn writeCases(io: std.Io, alloc: std.mem.Allocator, cases: []const Case, out_path: []const u8) !void { // Build the generated source in a buffer, then write it in one shot. var out: std.ArrayList(u8) = .empty; defer out.deinit(alloc); try out.appendSlice(alloc, "// AUTO-GENERATED by tutor_gen from tutor.txt. DO NOT EDIT.\n"); try out.appendSlice(alloc, "// One `test` block per practice block in the tutor. Re-run: zig build tutor-gen\n"); try out.appendSlice(alloc, "const std = @import(\"std\");\n"); try out.appendSlice(alloc, "const modal = @import(\"modal\");\n\n"); for (cases, 0..) |c, i| { // Derive a short human label from the keys for the test name. Sanitize // it to a valid Zig identifier fragment (spaces/specials -> '_'). var name_buf: [128]u8 = undefined; const printed = std.fmt.bufPrint(&name_buf, "tutor_case_{d:0>2}_{s}", .{ i, c.keys }) catch "tutor_case"; var san_buf: [128]u8 = undefined; const san_len = @min(printed.len, san_buf.len); for (printed[0..san_len], 0..) |ch, j| { san_buf[j] = switch (ch) { 'a'...'z', 'A'...'Z', '0'...'9', '_' => ch, else => '_', }; } const name_san = san_buf[0..san_len]; try out.print(alloc, "test \"{s}\" {{\n", .{name_san}); try out.appendSlice(alloc, " const a = std.testing.allocator;\n"); try out.appendSlice(alloc, " var yank: ?[]u8 = null;\n"); try out.appendSlice(alloc, " defer if (yank) |y| a.free(y);\n"); try out.appendSlice(alloc, " var scroll: usize = 0;\n"); try out.appendSlice(alloc, " var st = modal.FileState{};\n"); try out.appendSlice(alloc, " const view = modal.View{ .rows = 200, .scroll = &scroll };\n"); // starting content try out.appendSlice(alloc, " var content: []u8 = try a.dupe(u8, "); try emitStringLiteral(&out, alloc, c.before); try out.appendSlice(alloc, ");\n"); try out.appendSlice(alloc, " defer a.free(content);\n"); // starting cursor if (c.at) |at| { try out.print(alloc, " st.cur = .{{ .row = {s}, .col = {s} }};\n", .{ splitField(at, 0), splitField(at, 1), }); } // keystrokes var tit = std.mem.tokenizeAny(u8, c.keys, " "); while (tit.next()) |tok| { try emitKey(&out, alloc, tok); } // assertion try out.appendSlice(alloc, " try std.testing.expectEqualStrings("); try emitStringLiteral(&out, alloc, c.after); try out.appendSlice(alloc, ", content);\n"); try out.appendSlice(alloc, "}\n\n"); } try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = out.items }); } // "row,col" -> the row part (idx 0) or col part (idx 1). fn splitField(at: []const u8, idx: usize) []const u8 { var it = std.mem.splitScalar(u8, at, ','); var i: usize = 0; while (it.next()) |part| : (i += 1) { if (i == idx) return std.mem.trim(u8, part, " "); } return "0"; } // Emit the stepFile calls for one keystroke token. // One stepFile call, as a generated line. `key_expr` is the Zig Key payload // expression (e.g. `.esc`, `.ch = 'x'`). fn emitStep(out: *std.ArrayList(u8), alloc: std.mem.Allocator, key_expr: []const u8) !void { try out.print(alloc, " {{ const n = try modal.stepFile(a, content, &st, view, {s}, &yank); if (n) |nn| {{ a.free(content); content = nn; }} }}\n", .{key_expr}); } fn emitKey(out: *std.ArrayList(u8), alloc: std.mem.Allocator, tok: []const u8) !void { // named specials -> a single stepFile with that key if (std.mem.eql(u8, tok, "esc")) return emitStep(out, alloc, ".esc"); if (std.mem.eql(u8, tok, "enter")) return emitStep(out, alloc, ".enter"); if (std.mem.eql(u8, tok, "bs")) return emitStep(out, alloc, ".backspace"); if (std.mem.eql(u8, tok, "left")) return emitStep(out, alloc, ".left"); if (std.mem.eql(u8, tok, "right")) return emitStep(out, alloc, ".right"); if (std.mem.eql(u8, tok, "up")) return emitStep(out, alloc, ".up"); if (std.mem.eql(u8, tok, "down")) return emitStep(out, alloc, ".down"); if (std.mem.eql(u8, tok, "home")) return emitStep(out, alloc, ".home"); if (std.mem.eql(u8, tok, "end")) return emitStep(out, alloc, ".end"); if (std.mem.eql(u8, tok, "pgdn") or std.mem.eql(u8, tok, "pagedown")) return emitStep(out, alloc, ".page_down"); if (std.mem.eql(u8, tok, "pgup") or std.mem.eql(u8, tok, "pageup")) return emitStep(out, alloc, ".page_up"); if (std.mem.eql(u8, tok, "Ctrl-d")) return emitStep(out, alloc, ".half_down"); if (std.mem.eql(u8, tok, "Ctrl-u")) return emitStep(out, alloc, ".half_up"); if (std.mem.eql(u8, tok, "Ctrl-f")) return emitStep(out, alloc, ".page_down"); if (std.mem.eql(u8, tok, "Ctrl-b")) return emitStep(out, alloc, ".page_up"); // otherwise: each byte of the token is a typed char (in order) for (tok) |c| { var expr_buf: [32]u8 = undefined; const expr = std.fmt.bufPrint(&expr_buf, ".{{ .ch = {d} }}", .{c}) catch return; try emitStep(out, alloc, expr); } } pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa; const args = try init.minimal.args.toSlice(init.arena.allocator()); const in_path = if (args.len > 1) args[1] else "tutor.txt"; const out_path = if (args.len > 2) args[2] else "tutor_cases.zig"; const src = try std.Io.Dir.cwd().readFileAlloc(io, in_path, gpa, .limited(1 << 20)); defer gpa.free(src); const cases = try parseCases(gpa, src); defer { for (cases) |c| { gpa.free(c.keys); if (c.at) |at| gpa.free(at); gpa.free(c.before); gpa.free(c.after); } gpa.free(cases); } try writeCases(io, gpa, cases, out_path); std.debug.print("tutor_gen: wrote {d} test cases to {s}\n", .{ cases.len, out_path }); } // ---- self-test: the parser on tiny fixtures ---- test "parseCases extracts a before/after block" { const a = std.testing.allocator; const src = \\# keys: l l l \\# before \\abcdef \\# after \\abcdef \\ ; const cases = try parseCases(a, src); defer { for (cases) |c| { a.free(c.keys); if (c.at) |at| a.free(at); a.free(c.before); a.free(c.after); } a.free(cases); } try std.testing.expectEqual(@as(usize, 1), cases.len); try std.testing.expectEqualStrings("l l l", cases[0].keys); try std.testing.expectEqualStrings("abcdef", cases[0].before); try std.testing.expectEqualStrings("abcdef", cases[0].after); } test "parseCases handles # at: and multiple cases" { const a = std.testing.allocator; const src = \\# keys: b i Z esc \\# at: 0,4 \\# before \\foo bar \\# after \\Zfoo bar \\ \\# keys: x d \\# before \\keep \\gone \\# after \\keep \\ ; const cases = try parseCases(a, src); defer { for (cases) |c| { a.free(c.keys); if (c.at) |at| a.free(at); a.free(c.before); a.free(c.after); } a.free(cases); } try std.testing.expectEqual(@as(usize, 2), cases.len); try std.testing.expectEqualStrings("0,4", cases[0].at.?); try std.testing.expectEqualStrings("keep\ngone", cases[1].before); try std.testing.expectEqualStrings("keep", cases[1].after); }