4e65a5d1
scenario: add parser for the scenario runner DSL
a73x 2026-04-19 09:16
Parses size/timeout header + sleep/sleep-until-flip/bytes/ bytes-hex/capture/assert-cell/assert-cell-at directives. Errors return a Diagnostic with line number and static message. Pure — no I/O, no state machine yet. Plan 2 Task 2 builds on this. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/build.zig b/build.zig index 46cbc6a..880ec3a 100644 --- a/build.zig +++ b/build.zig @@ -382,6 +382,25 @@ pub fn build(b: *std.Build) void { const imgdiff_lib_tests = b.addTest(.{ .root_module = imgdiff_lib_test_mod }); test_step.dependOn(&b.addRunArtifact(imgdiff_lib_tests).step); // scenario — DSL parser, state machine, predicate evaluator (pure) const scenario_mod = b.createModule(.{ .root_source_file = b.path("src/scenario.zig"), .target = target, .optimize = optimize, }); scenario_mod.addImport("png", png_mod); scenario_mod.addImport("imgdiff", imgdiff_lib_mod); const scenario_test_mod = b.createModule(.{ .root_source_file = b.path("src/scenario.zig"), .target = target, .optimize = optimize, }); scenario_test_mod.addImport("png", png_mod); scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod); const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod }); test_step.dependOn(&b.addRunArtifact(scenario_tests).step); // imgdiff — standalone PNG comparison CLI const imgdiff_mod = b.createModule(.{ .root_source_file = b.path("src/tools/imgdiff.zig"), diff --git a/src/scenario.zig b/src/scenario.zig new file mode 100644 index 0000000..2d22461 --- /dev/null +++ b/src/scenario.zig @@ -0,0 +1,699 @@ //! Scenario runner — parser, state machine, and cell predicate evaluator. //! //! All APIs in this file are pure. Side-effectful work (pty writes, //! offscreen renders) is represented as callbacks that the caller //! supplies. See docs/superpowers/specs/2026-04-19-scenario-runner-design.md. const std = @import("std"); const png = @import("png"); const imgdiff = @import("imgdiff"); // --------------------------------------------------------------- // Directive types // --------------------------------------------------------------- pub const Predicate = enum { cell_matches_golden, cursor_block_at, cursor_bar_at, cursor_underline_at, cell_empty, }; pub const Directive = union(enum) { sleep: u64, // ms sleep_until_flip, bytes: []const u8, // owned by Scenario.arena capture: []const u8, // label, owned by Scenario.arena assert_cell: AssertCell, assert_cell_at: AssertCellAt, pub const AssertCell = struct { row: u16, col: u16, pred: Predicate, }; pub const AssertCellAt = struct { label: []const u8, // owned by Scenario.arena row: u16, col: u16, pred: Predicate, }; }; pub const Scenario = struct { cols: u16, rows: u16, timeout_ms: u64, directives: []const Directive, // Owns directive payload slices. arena: std.heap.ArenaAllocator, pub fn deinit(self: *Scenario) void { self.arena.deinit(); } }; pub const Diagnostic = struct { line: usize = 0, message: []const u8 = "", // static string }; pub const ParseError = error{ParseFailed} || std.mem.Allocator.Error; // --------------------------------------------------------------- // Parser implementation // --------------------------------------------------------------- fn parsePredicate(token: []const u8) ?Predicate { if (std.mem.eql(u8, token, "cell-matches-golden")) return .cell_matches_golden; if (std.mem.eql(u8, token, "cursor-block-at")) return .cursor_block_at; if (std.mem.eql(u8, token, "cursor-bar-at")) return .cursor_bar_at; if (std.mem.eql(u8, token, "cursor-underline-at")) return .cursor_underline_at; if (std.mem.eql(u8, token, "cell-empty")) return .cell_empty; return null; } /// Parse a duration token of the form "NNms" or "NNs". Returns ms. fn parseDuration(token: []const u8, diag: *Diagnostic, line: usize) ParseError!u64 { if (std.mem.endsWith(u8, token, "ms")) { const n = std.fmt.parseInt(u64, token[0 .. token.len - 2], 10) catch { diag.line = line; diag.message = "invalid sleep duration"; return error.ParseFailed; }; return n; } else if (std.mem.endsWith(u8, token, "s")) { const n = std.fmt.parseInt(u64, token[0 .. token.len - 1], 10) catch { diag.line = line; diag.message = "invalid sleep duration"; return error.ParseFailed; }; return n * 1000; } else { diag.line = line; diag.message = "sleep duration must end in 'ms' or 's'"; return error.ParseFailed; } } /// Parse a double-quoted string with escape sequences. /// Escape set: \n \t \r \e (→ 0x1B) \\ \" fn parseQuotedString( arena_alloc: std.mem.Allocator, token: []const u8, diag: *Diagnostic, line: usize, ) ParseError![]const u8 { if (token.len < 2 or token[0] != '"' or token[token.len - 1] != '"') { diag.line = line; diag.message = "bytes string must be double-quoted"; return error.ParseFailed; } const inner = token[1 .. token.len - 1]; var buf: std.ArrayList(u8) = .empty; var i: usize = 0; while (i < inner.len) { if (inner[i] == '\\') { if (i + 1 >= inner.len) { diag.line = line; diag.message = "trailing backslash in bytes string"; return error.ParseFailed; } const ch = inner[i + 1]; switch (ch) { 'n' => try buf.append(arena_alloc, '\n'), 't' => try buf.append(arena_alloc, '\t'), 'r' => try buf.append(arena_alloc, '\r'), 'e' => try buf.append(arena_alloc, 0x1B), '\\' => try buf.append(arena_alloc, '\\'), '"' => try buf.append(arena_alloc, '"'), else => { diag.line = line; diag.message = "unknown escape sequence in bytes string"; return error.ParseFailed; }, } i += 2; } else { try buf.append(arena_alloc, inner[i]); i += 1; } } return buf.toOwnedSlice(arena_alloc); } /// Split a line into whitespace-separated tokens. /// Handles the special case of a quoted string as the second token for `bytes`. fn nextToken(s: []const u8, start: usize) ?struct { tok: []const u8, end: usize } { var i = start; // skip leading whitespace while (i < s.len and (s[i] == ' ' or s[i] == '\t')) i += 1; if (i >= s.len) return null; const tok_start = i; if (s[i] == '"') { // consume until closing quote (no nesting) i += 1; while (i < s.len and s[i] != '"') { if (s[i] == '\\') i += 1; // skip escaped char if (i < s.len) i += 1; } if (i < s.len) i += 1; // consume closing quote return .{ .tok = s[tok_start..i], .end = i }; } else { while (i < s.len and s[i] != ' ' and s[i] != '\t') i += 1; return .{ .tok = s[tok_start..i], .end = i }; } } pub fn parse( gpa: std.mem.Allocator, source: []const u8, diag: *Diagnostic, ) ParseError!Scenario { var arena = std.heap.ArenaAllocator.init(gpa); errdefer arena.deinit(); const alloc = arena.allocator(); var directives: std.ArrayList(Directive) = .empty; var has_size = false; var has_timeout = false; var cols: u16 = 0; var rows: u16 = 0; var timeout_ms: u64 = 0; var lines = std.mem.splitScalar(u8, source, '\n'); var line_num: usize = 0; while (lines.next()) |raw_line| { line_num += 1; const line = std.mem.trim(u8, raw_line, " \t\r"); // Skip blank lines and comments if (line.len == 0 or line[0] == '#') continue; // Get first token (directive keyword) const kw_res = nextToken(line, 0) orelse continue; const kw = kw_res.tok; var pos = kw_res.end; if (std.mem.eql(u8, kw, "size")) { if (has_timeout) { diag.line = line_num; diag.message = "size directive must appear before timeout"; return error.ParseFailed; } if (has_size) { diag.line = line_num; diag.message = "duplicate size directive"; return error.ParseFailed; } const cols_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "size directive requires cols and rows"; return error.ParseFailed; }; pos = cols_res.end; const rows_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "size directive requires cols and rows"; return error.ParseFailed; }; cols = std.fmt.parseInt(u16, cols_res.tok, 10) catch { diag.line = line_num; diag.message = "size cols must be a positive integer"; return error.ParseFailed; }; rows = std.fmt.parseInt(u16, rows_res.tok, 10) catch { diag.line = line_num; diag.message = "size rows must be a positive integer"; return error.ParseFailed; }; has_size = true; } else if (std.mem.eql(u8, kw, "timeout")) { if (!has_size) { diag.line = line_num; diag.message = "expected size directive first"; return error.ParseFailed; } if (has_timeout) { diag.line = line_num; diag.message = "duplicate timeout directive"; return error.ParseFailed; } const dur_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "timeout directive requires a duration"; return error.ParseFailed; }; timeout_ms = try parseDuration(dur_res.tok, diag, line_num); has_timeout = true; } else { // Body directives require size + timeout first if (!has_size) { diag.line = line_num; diag.message = "expected size directive first"; return error.ParseFailed; } if (!has_timeout) { diag.line = line_num; diag.message = "expected timeout directive before body directives"; return error.ParseFailed; } if (std.mem.eql(u8, kw, "sleep")) { const dur_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "sleep directive requires a duration"; return error.ParseFailed; }; const ms = try parseDuration(dur_res.tok, diag, line_num); try directives.append(alloc, .{ .sleep = ms }); } else if (std.mem.eql(u8, kw, "sleep-until-flip")) { try directives.append(alloc, .sleep_until_flip); } else if (std.mem.eql(u8, kw, "bytes")) { const str_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "bytes directive requires a quoted string"; return error.ParseFailed; }; // Verify it starts with a quote; if not, the string was unquoted/unterminated if (str_res.tok.len == 0 or str_res.tok[0] != '"') { diag.line = line_num; diag.message = "bytes string must be double-quoted"; return error.ParseFailed; } // Check for unclosed quote: the token should end with '"' too if (str_res.tok.len < 2 or str_res.tok[str_res.tok.len - 1] != '"') { diag.line = line_num; diag.message = "unterminated string in bytes directive"; return error.ParseFailed; } const payload = try parseQuotedString(alloc, str_res.tok, diag, line_num); try directives.append(alloc, .{ .bytes = payload }); } else if (std.mem.eql(u8, kw, "bytes-hex")) { var hex_buf: std.ArrayList(u8) = .empty; var cur_pos = pos; while (nextToken(line, cur_pos)) |tok_res| { cur_pos = tok_res.end; const tok = tok_res.tok; if (tok.len != 2) { diag.line = line_num; diag.message = "bytes-hex tokens must be exactly 2 hex characters"; return error.ParseFailed; } const byte = std.fmt.parseInt(u8, tok, 16) catch { diag.line = line_num; diag.message = "bytes-hex token is not valid hexadecimal"; return error.ParseFailed; }; try hex_buf.append(alloc, byte); } try directives.append(alloc, .{ .bytes = try hex_buf.toOwnedSlice(alloc) }); } else if (std.mem.eql(u8, kw, "capture")) { const lbl_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "capture directive requires a label"; return error.ParseFailed; }; const lbl = lbl_res.tok; if (lbl.len == 0) { diag.line = line_num; diag.message = "capture label must not be empty"; return error.ParseFailed; } // Validate label chars: [a-zA-Z0-9_-]+ for (lbl) |ch| { if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-') { diag.line = line_num; diag.message = "capture label contains invalid characters"; return error.ParseFailed; } } const label_copy = try alloc.dupe(u8, lbl); try directives.append(alloc, .{ .capture = label_copy }); } else if (std.mem.eql(u8, kw, "assert-cell")) { const row_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "assert-cell requires row col predicate"; return error.ParseFailed; }; pos = row_res.end; const col_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "assert-cell requires row col predicate"; return error.ParseFailed; }; pos = col_res.end; const pred_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "assert-cell requires row col predicate"; return error.ParseFailed; }; const row = std.fmt.parseInt(u16, row_res.tok, 10) catch { diag.line = line_num; diag.message = "assert-cell row must be an integer"; return error.ParseFailed; }; const col = std.fmt.parseInt(u16, col_res.tok, 10) catch { diag.line = line_num; diag.message = "assert-cell col must be an integer"; return error.ParseFailed; }; const pred = parsePredicate(pred_res.tok) orelse { diag.line = line_num; diag.message = "unknown predicate"; return error.ParseFailed; }; try directives.append(alloc, .{ .assert_cell = .{ .row = row, .col = col, .pred = pred } }); } else if (std.mem.eql(u8, kw, "assert-cell-at")) { const lbl_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "assert-cell-at requires label row col predicate"; return error.ParseFailed; }; pos = lbl_res.end; const row_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "assert-cell-at requires label row col predicate"; return error.ParseFailed; }; pos = row_res.end; const col_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "assert-cell-at requires label row col predicate"; return error.ParseFailed; }; pos = col_res.end; const pred_res = nextToken(line, pos) orelse { diag.line = line_num; diag.message = "assert-cell-at requires label row col predicate"; return error.ParseFailed; }; const lbl = lbl_res.tok; for (lbl) |ch| { if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-') { diag.line = line_num; diag.message = "assert-cell-at label contains invalid characters"; return error.ParseFailed; } } const label_copy = try alloc.dupe(u8, lbl); const row = std.fmt.parseInt(u16, row_res.tok, 10) catch { diag.line = line_num; diag.message = "assert-cell-at row must be an integer"; return error.ParseFailed; }; const col = std.fmt.parseInt(u16, col_res.tok, 10) catch { diag.line = line_num; diag.message = "assert-cell-at col must be an integer"; return error.ParseFailed; }; const pred = parsePredicate(pred_res.tok) orelse { diag.line = line_num; diag.message = "unknown predicate"; return error.ParseFailed; }; try directives.append(alloc, .{ .assert_cell_at = .{ .label = label_copy, .row = row, .col = col, .pred = pred, } }); } else { diag.line = line_num; diag.message = "unknown directive"; return error.ParseFailed; } } } if (!has_size) { diag.line = 1; diag.message = "missing size directive"; return error.ParseFailed; } if (!has_timeout) { diag.line = line_num; diag.message = "missing timeout directive"; return error.ParseFailed; } return Scenario{ .cols = cols, .rows = rows, .timeout_ms = timeout_ms, .directives = try directives.toOwnedSlice(alloc), .arena = arena, }; } // --------------------------------------------------------------- // Parser tests // --------------------------------------------------------------- fn parseOk(source: []const u8) !Scenario { var diag: Diagnostic = .{}; return parse(std.testing.allocator, source, &diag) catch |err| { std.debug.print("unexpected parse error at line {}: {s}\n", .{ diag.line, diag.message }); return err; }; } fn parseErr(source: []const u8) !Diagnostic { var diag: Diagnostic = .{}; const result = parse(std.testing.allocator, source, &diag); try std.testing.expectError(error.ParseFailed, result); return diag; } test "parse: minimum valid scenario" { var s = try parseOk( \\size 80 24 \\timeout 1000ms ); defer s.deinit(); try std.testing.expectEqual(@as(u16, 80), s.cols); try std.testing.expectEqual(@as(u16, 24), s.rows); try std.testing.expectEqual(@as(u64, 1000), s.timeout_ms); try std.testing.expectEqual(@as(usize, 0), s.directives.len); } test "parse: comments and blank lines ignored" { var s = try parseOk( \\# leading comment \\ \\size 80 24 \\ # indented comment \\timeout 500ms \\ ); defer s.deinit(); try std.testing.expectEqual(@as(u16, 80), s.cols); } test "parse: missing size directive fails at line 1" { const diag = try parseErr( \\timeout 500ms ); try std.testing.expectEqual(@as(usize, 1), diag.line); try std.testing.expect(std.mem.indexOf(u8, diag.message, "size") != null); } test "parse: missing timeout directive fails" { const diag = try parseErr( \\size 80 24 ); try std.testing.expect(std.mem.indexOf(u8, diag.message, "timeout") != null); } test "parse: unknown directive fails at its line" { const diag = try parseErr( \\size 80 24 \\timeout 500ms \\what-is-this ); try std.testing.expectEqual(@as(usize, 3), diag.line); try std.testing.expect(std.mem.indexOf(u8, diag.message, "unknown directive") != null); } test "parse: sleep directive" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\sleep 250ms \\sleep 1s ); defer s.deinit(); try std.testing.expectEqual(@as(usize, 2), s.directives.len); try std.testing.expectEqual(@as(u64, 250), s.directives[0].sleep); try std.testing.expectEqual(@as(u64, 1000), s.directives[1].sleep); } test "parse: sleep-until-flip directive" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\sleep-until-flip ); defer s.deinit(); try std.testing.expectEqual(@as(usize, 1), s.directives.len); try std.testing.expect(s.directives[0] == .sleep_until_flip); } test "parse: bytes directive — plain ASCII" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\bytes "hello" ); defer s.deinit(); try std.testing.expectEqualSlices(u8, "hello", s.directives[0].bytes); } test "parse: bytes directive — escape set" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\bytes "a\eb\nc\td\\e\"f\r" ); defer s.deinit(); // Expected bytes: 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D try std.testing.expectEqualSlices( u8, &[_]u8{ 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D }, s.directives[0].bytes, ); } test "parse: bytes directive — unclosed string fails" { const diag = try parseErr( \\size 80 24 \\timeout 5000ms \\bytes "no-close ); try std.testing.expectEqual(@as(usize, 3), diag.line); } test "parse: bytes directive — unknown escape fails" { const diag = try parseErr( \\size 80 24 \\timeout 5000ms \\bytes "bad \q escape" ); try std.testing.expectEqual(@as(usize, 3), diag.line); try std.testing.expect(std.mem.indexOf(u8, diag.message, "escape") != null); } test "parse: bytes-hex directive" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\bytes-hex 1B 5B 35 20 71 ); defer s.deinit(); try std.testing.expectEqualSlices(u8, &[_]u8{ 0x1B, 0x5B, 0x35, 0x20, 0x71 }, s.directives[0].bytes); } test "parse: bytes-hex accepts mixed case" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\bytes-hex aB cD eF ); defer s.deinit(); try std.testing.expectEqualSlices(u8, &[_]u8{ 0xAB, 0xCD, 0xEF }, s.directives[0].bytes); } test "parse: bytes-hex rejects odd-length token" { const diag = try parseErr( \\size 80 24 \\timeout 5000ms \\bytes-hex 1B 5 ); try std.testing.expectEqual(@as(usize, 3), diag.line); } test "parse: bytes-hex rejects non-hex characters" { const diag = try parseErr( \\size 80 24 \\timeout 5000ms \\bytes-hex 1B ZZ ); try std.testing.expectEqual(@as(usize, 3), diag.line); } test "parse: capture directive — label stored" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\capture on-phase ); defer s.deinit(); try std.testing.expectEqualSlices(u8, "on-phase", s.directives[0].capture); } test "parse: capture directive — empty label fails" { const diag = try parseErr( \\size 80 24 \\timeout 5000ms \\capture ); try std.testing.expectEqual(@as(usize, 3), diag.line); } test "parse: assert-cell directive" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\assert-cell 5 3 cursor-bar-at ); defer s.deinit(); const a = s.directives[0].assert_cell; try std.testing.expectEqual(@as(u16, 5), a.row); try std.testing.expectEqual(@as(u16, 3), a.col); try std.testing.expectEqual(Predicate.cursor_bar_at, a.pred); } test "parse: assert-cell-at directive" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\assert-cell-at on-phase 5 3 cursor-block-at ); defer s.deinit(); const a = s.directives[0].assert_cell_at; try std.testing.expectEqualSlices(u8, "on-phase", a.label); try std.testing.expectEqual(@as(u16, 5), a.row); try std.testing.expectEqual(@as(u16, 3), a.col); try std.testing.expectEqual(Predicate.cursor_block_at, a.pred); } test "parse: all five predicates round-trip through assert-cell" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\assert-cell 0 0 cell-matches-golden \\assert-cell 0 0 cursor-block-at \\assert-cell 0 0 cursor-bar-at \\assert-cell 0 0 cursor-underline-at \\assert-cell 0 0 cell-empty ); defer s.deinit(); try std.testing.expectEqual(Predicate.cell_matches_golden, s.directives[0].assert_cell.pred); try std.testing.expectEqual(Predicate.cursor_block_at, s.directives[1].assert_cell.pred); try std.testing.expectEqual(Predicate.cursor_bar_at, s.directives[2].assert_cell.pred); try std.testing.expectEqual(Predicate.cursor_underline_at, s.directives[3].assert_cell.pred); try std.testing.expectEqual(Predicate.cell_empty, s.directives[4].assert_cell.pred); } test "parse: unknown predicate fails at line" { const diag = try parseErr( \\size 80 24 \\timeout 5000ms \\assert-cell 0 0 totally-bogus ); try std.testing.expectEqual(@as(usize, 3), diag.line); }