summaryrefslogtreecommitdiff
path: root/tutor_gen.zig
blob: 09875e64bb9a301c6698572eafee57f7665840df (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
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);
}