diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-30 08:57:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-30 11:43:29 -0300 |
| commit | 8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch) | |
| tree | 788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 /tutor_gen.zig | |
| parent | b5b526f465924324e2cd4424f5bacc43e50b7673 (diff) | |
| download | pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip | |
chinese vibes modal editing
Diffstat (limited to 'tutor_gen.zig')
| -rw-r--r-- | tutor_gen.zig | 386 |
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); +} |
