diff --git a/src/synthetic/cli.zig b/src/synthetic/cli.zig index dcac0d0dc..560896030 100644 --- a/src/synthetic/cli.zig +++ b/src/synthetic/cli.zig @@ -9,6 +9,7 @@ pub const Action = enum { ascii, kitty, osc, + styled, utf8, /// Returns the struct associated with the action. The struct @@ -24,6 +25,7 @@ pub const Action = enum { .ascii => @import("cli/Ascii.zig"), .kitty => @import("cli/Kitty.zig"), .osc => @import("cli/Osc.zig"), + .styled => @import("cli/Styled.zig"), .utf8 => @import("cli/Utf8.zig"), }; } diff --git a/src/synthetic/cli/Styled.zig b/src/synthetic/cli/Styled.zig new file mode 100644 index 000000000..c437a6ab5 --- /dev/null +++ b/src/synthetic/cli/Styled.zig @@ -0,0 +1,410 @@ +//! Generates styled terminal content: lines of printable text +//! interspersed with SGR sequences, optional multi-byte UTF-8 +//! codepoints, combining marks (multi-codepoint graphemes), and +//! OSC 8 hyperlinks. +//! +//! This exists primarily to build corpora for benchmarking code that +//! consumes terminal *contents* (e.g. the terminal formatter used for +//! clipboard copy and dumps), where we want workloads with controlled +//! amounts of styling and Unicode rather than raw random bytes. +//! +//! Examples: +//! +//! # Plain ASCII lines (equivalent to `ascii` with lines) +//! ghostty-gen styled --seed=42 +//! +//! # Heavily styled ASCII +//! ghostty-gen styled --seed=42 --style-rate=0.8 +//! +//! # Unicode-heavy, no styling +//! ghostty-gen styled --seed=42 --weight-two=1 --weight-three=1 \ +//! --weight-four=0.5 --grapheme-rate=0.1 +//! +//! # Mixed: styles, Unicode, and hyperlinks +//! ghostty-gen styled --seed=42 --style-rate=0.3 --weight-two=0.5 \ +//! --weight-three=0.25 --weight-four=0.1 --grapheme-rate=0.05 \ +//! --osc8-rate=0.1 +const Styled = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +pub const Options = struct { + /// Seed to use for deterministic generation. If unset, a time-based + /// seed is used by the generic synthetic CLI. + seed: ?u64 = null, + + /// Emit lines whose printable length (in characters, not columns) + /// is uniformly distributed in `[line-min, line-max]`. Each line is + /// terminated by CR LF. + @"line-min": usize = 40, + @"line-max": usize = 80, + + /// Probability that a style change (SGR sequence) is emitted at + /// each run boundary. Zero (default) produces unstyled output. + @"style-rate": f64 = 0.0, + + /// The length in characters of a "run": a sequence of characters + /// that share styling. Style/hyperlink changes only happen on run + /// boundaries. + @"run-min": usize = 4, + @"run-max": usize = 16, + + /// Relative weights for choosing the UTF-8 encoding length of each + /// generated character. Unlike the raw `utf8` generator, characters + /// are drawn from curated printable ranges so the output never + /// contains control characters: + /// + /// one: printable ASCII (with occasional spaces) + /// two: Latin-1 Supplement / Latin Extended (narrow) + /// three: Hiragana/Katakana and CJK ideographs (wide) + /// four: emoji (wide) + @"weight-one": f64 = 1.0, + @"weight-two": f64 = 0.0, + @"weight-three": f64 = 0.0, + @"weight-four": f64 = 0.0, + + /// Probability that a generated character is followed by a + /// combining diacritical mark, producing a multi-codepoint + /// grapheme cluster. + @"grapheme-rate": f64 = 0.0, + + /// Probability that an OSC 8 hyperlink is toggled (opened or + /// closed) at each run boundary. + @"osc8-rate": f64 = 0.0, +}; + +opts: Options, + +pub fn create( + alloc: Allocator, + opts: Options, +) !*Styled { + for ([_]f64{ + opts.@"style-rate", + opts.@"grapheme-rate", + opts.@"osc8-rate", + }) |rate| { + if (rate < 0 or rate > 1) return error.InvalidValue; + } + + const weights = [_]f64{ + opts.@"weight-one", + opts.@"weight-two", + opts.@"weight-three", + opts.@"weight-four", + }; + var weight_sum: f64 = 0; + for (weights) |weight| { + if (weight < 0) return error.InvalidValue; + weight_sum += weight; + } + if (weight_sum <= 0) return error.InvalidValue; + + if (opts.@"line-min" == 0) return error.InvalidValue; + if (opts.@"run-min" == 0) return error.InvalidValue; + + const ptr = try alloc.create(Styled); + errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; + return ptr; +} + +pub fn destroy(self: *Styled, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn run(self: *Styled, writer: *std.Io.Writer, rand: std.Random) !void { + var prng: ?std.Random.DefaultPrng = null; + var gen_rand = rand; + if (self.opts.seed) |seed| { + prng = std.Random.DefaultPrng.init(seed); + gen_rand = prng.?.random(); + } + + var state: State = .{}; + while (true) { + self.writeLine(writer, gen_rand, &state) catch |err| { + const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); + switch (@as(Error, err)) { + error.BrokenPipe => return, // stdout closed + error.WriteFailed => return, // fixed buffer full + } + }; + } +} + +const State = struct { + /// True when a non-default SGR style is currently active. + styled: bool = false, + + /// True when an OSC 8 hyperlink is currently open. + link: bool = false, +}; + +fn writeLine( + self: *Styled, + writer: *std.Io.Writer, + rand: std.Random, + state: *State, +) std.Io.Writer.Error!void { + const line_min = self.opts.@"line-min"; + const line_max = @max(line_min, self.opts.@"line-max"); + const run_min = self.opts.@"run-min"; + const run_max = @max(run_min, self.opts.@"run-max"); + + const line_len = rand.intRangeAtMostBiased(usize, line_min, line_max); + var remaining = line_len; + while (remaining > 0) { + if (self.opts.@"style-rate" > 0 and + rand.float(f64) < self.opts.@"style-rate") + { + try self.writeSgr(writer, rand, state); + } + + if (self.opts.@"osc8-rate" > 0 and + rand.float(f64) < self.opts.@"osc8-rate") + { + try self.toggleLink(writer, rand, state); + } + + const run_len = @min( + remaining, + rand.intRangeAtMostBiased(usize, run_min, run_max), + ); + for (0..run_len) |_| try self.writeChar(writer, rand); + remaining -= run_len; + } + + // Close any open styling/hyperlink so state never bleeds across + // lines. This mirrors what well-behaved programs do. + if (state.styled) { + try writer.writeAll("\x1b[0m"); + state.styled = false; + } + if (state.link) { + try writer.writeAll("\x1b]8;;\x1b\\"); + state.link = false; + } + + try writer.writeAll("\r\n"); +} + +fn writeChar( + self: *Styled, + writer: *std.Io.Writer, + rand: std.Random, +) std.Io.Writer.Error!void { + const weights = [_]f64{ + self.opts.@"weight-one", + self.opts.@"weight-two", + self.opts.@"weight-three", + self.opts.@"weight-four", + }; + + const cp: u21 = switch (rand.weightedIndex(f64, &weights)) { + // Printable ASCII with occasional word-ish spacing. + 0 => if (rand.float(f64) < 0.15) + ' ' + else + rand.intRangeAtMostBiased(u21, 0x21, 0x7E), + + // Latin-1 Supplement and Latin Extended-A/B (narrow). + 1 => rand.intRangeAtMostBiased(u21, 0xC0, 0x24F), + + // Kana or CJK ideographs (wide). + 2 => if (rand.boolean()) + rand.intRangeAtMostBiased(u21, 0x3041, 0x30FE) + else + rand.intRangeAtMostBiased(u21, 0x4E00, 0x9FFF), + + // Emoji (wide). + 3 => rand.intRangeAtMostBiased(u21, 0x1F600, 0x1F64F), + + else => unreachable, + }; + + try writer.print("{u}", .{cp}); + + // Optionally follow with a combining diacritical mark to form a + // multi-codepoint grapheme cluster. + if (self.opts.@"grapheme-rate" > 0 and + rand.float(f64) < self.opts.@"grapheme-rate") + { + const mark = rand.intRangeAtMostBiased(u21, 0x300, 0x36F); + try writer.print("{u}", .{mark}); + } +} + +fn writeSgr( + self: *Styled, + writer: *std.Io.Writer, + rand: std.Random, + state: *State, +) std.Io.Writer.Error!void { + _ = self; + + switch (rand.uintLessThan(u8, 8)) { + // Reset. + 0 => { + try writer.writeAll("\x1b[0m"); + state.styled = false; + return; + }, + + // 16-color foreground. + 1 => { + const base: u8 = if (rand.boolean()) 30 else 90; + try writer.print("\x1b[{d}m", .{base + rand.uintLessThan(u8, 8)}); + }, + + // 16-color background. + 2 => { + const base: u8 = if (rand.boolean()) 40 else 100; + try writer.print("\x1b[{d}m", .{base + rand.uintLessThan(u8, 8)}); + }, + + // 256-color foreground/background. + 3 => try writer.print("\x1b[38;5;{d}m", .{rand.int(u8)}), + 4 => try writer.print("\x1b[48;5;{d}m", .{rand.int(u8)}), + + // RGB foreground/background. Components are quantized so the + // number of unique styles stays bounded (6^3 per channel pair) + // rather than pathologically unique per cell. + 5, 6 => |kind| { + const layer: u8 = if (kind == 5) 38 else 48; + try writer.print("\x1b[{d};2;{d};{d};{d}m", .{ + layer, + rand.uintLessThan(u8, 6) * 51, + rand.uintLessThan(u8, 6) * 51, + rand.uintLessThan(u8, 6) * 51, + }); + }, + + // Attributes: bold, italic, underline, inverse, strikethrough. + 7 => { + const attrs = [_]u8{ 1, 3, 4, 7, 9 }; + try writer.print("\x1b[{d}m", .{ + attrs[rand.uintLessThan(usize, attrs.len)], + }); + }, + + else => unreachable, + } + + state.styled = true; +} + +fn toggleLink( + self: *Styled, + writer: *std.Io.Writer, + rand: std.Random, + state: *State, +) std.Io.Writer.Error!void { + _ = self; + + if (state.link) { + try writer.writeAll("\x1b]8;;\x1b\\"); + state.link = false; + return; + } + + try writer.print( + "\x1b]8;;http://example.com/{d}\x1b\\", + .{rand.uintLessThan(u16, 1024)}, + ); + state.link = true; +} + +test Styled { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Styled = try .create(alloc, .{ .seed = 1 }); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); + const output = writer.buffered(); + try testing.expect(output.len > 0); + + // Default options: plain printable ASCII lines only. + for (output) |byte| { + try testing.expect(byte == '\r' or byte == '\n' or + (byte >= 0x20 and byte < 0x7F)); + } +} + +test "Styled styled output" { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Styled = try .create(alloc, .{ + .seed = 1, + .@"style-rate" = 0.5, + .@"osc8-rate" = 0.2, + }); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [16384]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); + const output = writer.buffered(); + + // Must contain SGR and OSC 8 sequences. + try testing.expect(std.mem.indexOf(u8, output, "\x1b[") != null); + try testing.expect(std.mem.indexOf(u8, output, "\x1b]8;;") != null); +} + +test "Styled unicode output" { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Styled = try .create(alloc, .{ + .seed = 1, + .@"weight-two" = 1.0, + .@"weight-three" = 1.0, + .@"weight-four" = 1.0, + .@"grapheme-rate" = 0.25, + }); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [16384]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); + const output = writer.buffered(); + + // No escapes, valid UTF-8 (modulo a possibly truncated tail from + // the fixed buffer filling up mid-sequence). + try testing.expect(std.mem.indexOfScalar(u8, output, 0x1B) == null); + var end = output.len; + while (end > 0 and output[end - 1] & 0xC0 == 0x80) end -= 1; + if (end > 0 and output[end - 1] >= 0xC0) end -= 1; + try testing.expect(std.unicode.utf8ValidateSlice(output[0..end])); +} + +test "Styled invalid options" { + const testing = std.testing; + const alloc = testing.allocator; + + try testing.expectError(error.InvalidValue, Styled.create(alloc, .{ + .@"style-rate" = 1.5, + })); + try testing.expectError(error.InvalidValue, Styled.create(alloc, .{ + .@"weight-one" = 0, + })); + try testing.expectError(error.InvalidValue, Styled.create(alloc, .{ + .@"line-min" = 0, + })); +}