synthetic: styled output generator

This commit is contained in:
Mitchell Hashimoto
2026-08-03 14:10:57 -07:00
parent 7e50356642
commit b11d60818a
2 changed files with 412 additions and 0 deletions

View File

@@ -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"),
};
}

View File

@@ -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,
}));
}