summaryrefslogtreecommitdiff
path: root/tutor_gen.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 08:57:43 -0300
committerGabriel Schneider <[email protected]>2026-06-30 11:43:29 -0300
commit8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch)
tree788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 /tutor_gen.zig
parentb5b526f465924324e2cd4424f5bacc43e50b7673 (diff)
downloadpardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz
pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip
chinese vibes modal editing
Diffstat (limited to 'tutor_gen.zig')
-rw-r--r--tutor_gen.zig386
1 files changed, 386 insertions, 0 deletions
diff --git a/tutor_gen.zig b/tutor_gen.zig
new file mode 100644
index 00000000..09875e64
--- /dev/null
+++ b/tutor_gen.zig
@@ -0,0 +1,386 @@
+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
+// <clean line>
+// # after <- the lines below are the expected buffer
+// <clean line>
+// 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);
+}