//! Build-time generator: a vendored helix or zed theme in, one `.zig` file per //! theme out. build.zig runs it before anything compiles and hands the output //! directory to src/pardes.zig as the `generated_themes` module, so a theme is //! an ordinary source file by the time the compiler sees it. //! //! ADDING A THEME IS DROPPING A FILE INTO `vendor/themes/`: a helix `.toml` or //! a zed `.json`. The extension picks the reader and records where it came //! from; build.zig reads the directory, so there is no list anywhere to append //! to. A zed file holds a whole FAMILY (one.json is One Dark and One Light), so //! one input can be several themes out. //! //! Why vendored and not read out of ~/05-genizah: `zig build` must work on a //! fresh clone with no network and no helix checkout. The files are small and //! we consume a dozen fields out of each, so a curated handful beats a mirror //! of all 214 helix ships — bulk buys nothing when pardes throws away 95% of //! what a theme carries. //! //! Provenance: vendor/themes/*.toml are helix's own runtime themes (from //! helix-editor/helix runtime/themes, each file keeping its author's header); //! *.json are zed's (zed-industries/zed assets/themes). Every one carries its //! upstream licence beside it as `.LICENSE`, and build.zig's directory //! read skips anything that is not a .toml or a .json so they can sit there. //! //! A theme that cannot be mapped is FATAL, naming the file and the key. The //! vendored set is curated, so a missing key means the file is not what this //! reader thinks it is — printing a warning and emitting a black-on-black theme //! would put that mistake on screen instead of in the build log. const std = @import("std"); const Rgb = [3]u8; /// pardes's Theme, minus the type: the generated file spells an anonymous /// `.{...}` because a theme file deliberately imports NOTHING (it is data, not /// code) — src/pardes.zig's `fold` is what turns it into a real Theme. const Theme = struct { name: []const u8, bg: ?Rgb, fg: ?Rgb, tag_bg: Rgb, tag_fg: Rgb, box: Rgb, box_dim: Rgb, kw: Rgb, str: Rgb, num: Rgb, comment: Rgb, lineno: Rgb, scroll_track: Rgb, scroll_thumb: Rgb, palette: ?[16]Rgb, }; pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa; const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena); if (args.len < 3) std.process.fatal("usage: gen-themes ...", .{}); var themes: std.ArrayList(Theme) = .empty; for (args[2..]) |src| { const text = try std.Io.Dir.cwd().readFileAlloc(io, src, arena, .limited(8 << 20)); if (std.mem.endsWith(u8, src, ".toml")) try readHelix(arena, src, text, &themes) else if (std.mem.endsWith(u8, src, ".json")) try readZed(arena, gpa, src, text, &themes) else std.process.fatal("{s}: not a helix .toml or a zed .json", .{src}); } // sorted, so the ring's tail is the same on every machine whatever order // the directory happened to be read in std.mem.sort(Theme, themes.items, {}, byName); var dir = try std.Io.Dir.cwd().openDir(io, args[1], .{}); defer dir.close(io); var buf: std.Io.Writer.Allocating = .init(arena); for (themes.items) |t| { buf.clearRetainingCapacity(); try emit(&buf.writer, t); try write(io, dir, arena, t.name, buf.written()); } // the INDEX, and the only reason it is generated rather than written: a zig // file cannot list a directory at comptime, so the imports have to come // from whoever can. pardes.zig folds its declarations into the ring. buf.clearRetainingCapacity(); try buf.writer.writeAll("// Generated by tools/gen_themes.zig. Do not edit.\n"); for (themes.items) |t| try buf.writer.print("pub const {s} = @import(\"{s}.zig\");\n", .{ t.name, t.name }); try write(io, dir, arena, "list", buf.written()); } fn write(io: std.Io, dir: std.Io.Dir, arena: std.mem.Allocator, stem: []const u8, text: []const u8) !void { const file = try dir.createFile(io, try std.fmt.allocPrint(arena, "{s}.zig", .{stem}), .{}); defer file.close(io); try file.writeStreamingAll(io, text); } fn byName(_: void, a: Theme, b: Theme) bool { return std.mem.order(u8, a.name, b.name) == .lt; } fn emit(w: *std.Io.Writer, t: Theme) !void { try w.writeAll("// Generated by tools/gen_themes.zig. Do not edit.\npub const theme = .{\n"); try w.print(" .name = \"{s}\",\n", .{t.name}); inline for (@typeInfo(Theme).@"struct".fields) |f| { if (comptime std.mem.eql(u8, f.name, "name")) continue; if (comptime std.mem.eql(u8, f.name, "palette")) continue; try w.print(" .{s} = ", .{f.name}); if (comptime @typeInfo(f.type) == .optional) { if (@field(t, f.name)) |c| try rgb(w, c) else try w.writeAll("null"); } else try rgb(w, @field(t, f.name)); try w.writeAll(",\n"); } if (t.palette) |p| { try w.writeAll(" .palette = .{\n"); for (p) |c| { try w.writeAll(" "); try rgb(w, c); try w.writeAll(",\n"); } try w.writeAll(" },\n"); } else try w.writeAll(" .palette = null,\n"); try w.writeAll("};\n"); } fn rgb(w: *std.Io.Writer, c: Rgb) !void { try w.print(".{{ 0x{x:0>2}, 0x{x:0>2}, 0x{x:0>2} }}", .{ c[0], c[1], c[2] }); } /// `Ayu Mirage` -> `ayu_mirage`: the user-visible theme NAME, the generated /// file's stem and its declaration in the index, all one string. It has to be a /// single word (a `Theme ` row is executed by splitting on the first /// space) and a legal identifier (it is a decl in list.zig). fn slug(arena: std.mem.Allocator, raw: []const u8) ![]const u8 { var out: std.ArrayList(u8) = .empty; for (raw) |c| { if (std.ascii.isAlphanumeric(c)) try out.append(arena, std.ascii.toLower(c)) // one underscore per run of punctuation, and never a trailing one else if (out.items.len > 0 and out.items[out.items.len - 1] != '_') try out.append(arena, '_'); } const s = std.mem.trim(u8, out.items, "_"); if (s.len == 0 or !std.ascii.isAlphabetic(s[0])) std.process.fatal("theme name {s} does not slug to an identifier", .{raw}); return s; } // ---- helix (.toml) ---- // Not a TOML parser: helix themes are one `key = value` per line, where a value // is either a bare color string or an inline table, plus a `[palette]` section // of name -> color. A real parser would be a dependency and several hundred // lines to read a shape this flat. /// what a key was assigned, unresolved — `fg`/`bg` name a palette entry or a /// literal `#rrggbb`, and helix's bare form (`"ui.text" = "nord4"`) is an fg const Pair = struct { fg: ?[]const u8 = null, bg: ?[]const u8 = null }; fn readHelix(arena: std.mem.Allocator, path: []const u8, text: []const u8, out: *std.ArrayList(Theme)) !void { var palette: std.StringHashMapUnmanaged([]const u8) = .empty; var keys: std.StringHashMapUnmanaged(Pair) = .empty; var in_palette = false; var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, uncomment(raw), " \t\r"); if (line.len == 0) continue; if (line[0] == '[') { in_palette = std.mem.eql(u8, line, "[palette]"); continue; } const eq = topLevel(line, '=') orelse continue; const key = unquote(std.mem.trim(u8, line[0..eq], " \t")); const val = std.mem.trim(u8, line[eq + 1 ..], " \t"); if (val.len == 0) continue; if (in_palette) { try palette.put(arena, key, unquote(val)); } else if (quoted(val)) { try keys.put(arena, key, .{ .fg = unquote(val) }); } else if (val[0] == '{') { try keys.put(arena, key, table(val)); } } // `inherits = "other"` needs a second file we did not read, so the theme we // would emit is only the half that overrides. Vendor the parent instead. if (keys.contains("inherits")) std.process.fatal("{s}: inherits= is not supported; vendor a theme that stands alone", .{path}); const h: Helix = .{ .path = path, .keys = keys, .palette = palette }; // helix's own rule for anything a theme left unset: the page's colors. const fg = h.need("ui.text", .fg); const bg = h.need("ui.background", .bg); // The move box is the accent that says "here". helix's is the primary // cursor's background — but a theme that draws its cursor by REVERSING // video (nord, monokai) never names one, so the fallback is the loudest // hue the theme did commit to. const box = h.first(&.{ .{ "ui.cursor.primary", .bg }, .{ "ui.cursor", .bg }, .{ "keyword", .fg } }) orelse std.process.fatal("{s}: no ui.cursor.primary/ui.cursor background and no keyword color", .{path}); const sel = h.get("ui.selection", .bg) orelse bg; try out.append(arena, .{ .name = try slug(arena, std.fs.path.stem(path)), // explicit rather than null: a helix theme dresses its whole page, and // half-dressing it over whatever the host terminal defaults to is how // you get grey-on-grey. `palette` stays null for the opposite reason — // helix leaves the colors a PROGRAM emits alone, and so do we. .bg = bg, .fg = fg, .tag_bg = h.get("ui.statusline", .bg) orelse bg, .tag_fg = h.get("ui.statusline", .fg) orelse fg, .box = box, .box_dim = sel, .kw = h.need("keyword", .fg), .str = h.need("string", .fg), // most themes only colour `constant`; the ones that split out numbers // are being more specific, so they win .num = h.first(&.{ .{ "constant.numeric", .fg }, .{ "constant", .fg } }) orelse std.process.fatal("{s}: no constant.numeric or constant color", .{path}), .comment = h.need("comment", .fg), .lineno = h.get("ui.linenr", .fg) orelse fg, // the scrollbar is chrome, so it takes the chrome colors: the tag bar's // own background for the track, the selection (one step brighter than // anything else in the theme's greys) for the thumb riding it .scroll_track = h.get("ui.statusline", .bg) orelse bg, .scroll_thumb = sel, .palette = null, }); } const Which = enum { fg, bg }; const Helix = struct { path: []const u8, keys: std.StringHashMapUnmanaged(Pair), palette: std.StringHashMapUnmanaged([]const u8), fn get(h: Helix, key: []const u8, which: Which) ?Rgb { const p = h.keys.get(key) orelse return null; return h.color(switch (which) { .fg => p.fg orelse return null, .bg => p.bg orelse return null, }); } fn need(h: Helix, key: []const u8, which: Which) Rgb { return h.get(key, which) orelse std.process.fatal("{s}: missing {s}.{s}", .{ h.path, key, @tagName(which) }); } fn first(h: Helix, tries: []const struct { []const u8, Which }) ?Rgb { for (tries) |t| if (h.get(t[0], t[1])) |c| return c; return null; } /// a literal `#rrggbb`, or a `[palette]` name that resolves to one. A /// palette entry may itself be a name (helix lets one alias another), so /// this walks — bounded, because a cycle is a broken theme and not a hang. fn color(h: Helix, start: []const u8) Rgb { var name = start; for (0..8) |_| { if (name.len > 0 and name[0] == '#') return hex(h.path, name); name = h.palette.get(name) orelse std.process.fatal("{s}: color \"{s}\" is neither #rrggbb nor a [palette] entry", .{ h.path, start }); } std.process.fatal("{s}: [palette] entry \"{s}\" never resolves to a color", .{ h.path, start }); } }; /// cut a `#` comment, unless it is inside a string — where every colour in the /// file lives (`bg = "#282828" # main background`). TOML has two string /// quotes and themes use both, sometimes in the same file: solarized writes its /// whole palette in '...'. fn uncomment(line: []const u8) []const u8 { var q: u8 = 0; for (line, 0..) |c, i| { if (q != 0) { if (c == q) q = 0; } else if (c == '"' or c == '\'') q = c else if (c == '#') return line[0..i]; } return line; } /// index of the first `c` outside a string and outside brackets — the `=` that /// separates a key from its value, or the commas between an inline table's /// entries (an `underline = { color = ..., style = ... }` nested inside must /// not split) fn topLevel(s: []const u8, c: u8) ?usize { var q: u8 = 0; var depth: usize = 0; for (s, 0..) |ch, i| { if (q != 0) { if (ch == q) q = 0; continue; } switch (ch) { '"', '\'' => q = ch, '{', '[' => depth += 1, '}', ']' => depth -|= 1, else => if (ch == c and depth == 0) return i, } } return null; } fn quoted(s: []const u8) bool { return s.len > 0 and (s[0] == '"' or s[0] == '\''); } fn unquote(s: []const u8) []const u8 { if (s.len >= 2 and quoted(s) and s[s.len - 1] == s[0]) return s[1 .. s.len - 1]; return s; } /// `{ fg = "red1", modifiers = ["italic"] }` -> the fg/bg it names fn table(s: []const u8) Pair { var out: Pair = .{}; const body = s[1 .. s.len - @intFromBool(s[s.len - 1] == '}')]; var rest = body; while (rest.len > 0) { const end = topLevel(rest, ',') orelse rest.len; const item = rest[0..end]; rest = rest[@min(end + 1, rest.len)..]; const eq = topLevel(item, '=') orelse continue; const k = std.mem.trim(u8, item[0..eq], " \t"); const v = std.mem.trim(u8, item[eq + 1 ..], " \t"); if (!quoted(v)) continue; if (std.mem.eql(u8, k, "fg")) out.fg = unquote(v); if (std.mem.eql(u8, k, "bg")) out.bg = unquote(v); } return out; } // ---- zed (.json) ---- // One file is a FAMILY: `themes` is an array of variants, each with a flat // `style` map of dotted names plus a `style.syntax` map of {color, ...}. // Everything is spelled out (no palette indirection), and unlike helix a zed // theme names all sixteen terminal ANSI colors — which is the whole reason to // take them: a light theme is only usable if the shell's own output is // repainted for it, and pardes's acme-light is the hand-written proof. fn readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, text: []const u8, out: *std.ArrayList(Theme)) !void { const parsed = std.json.parseFromSlice(std.json.Value, gpa, text, .{}) catch std.process.fatal("{s}: not valid JSON", .{path}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |o| o, else => std.process.fatal("{s}: top level is not an object", .{path}), }; const list = root.get("themes") orelse std.process.fatal("{s}: no `themes` array", .{path}); for (list.array.items) |variant| { const v = variant.object; const z: Zed = .{ .path = path, .style = (v.get("style") orelse std.process.fatal("{s}: a variant has no `style`", .{path})).object, }; const syntax = (z.style.get("syntax") orelse std.process.fatal("{s}: a variant has no `style.syntax`", .{path})).object; var pal: [16]Rgb = undefined; for (ansi, 0..) |n, i| { pal[i] = z.need(try std.fmt.allocPrint(arena, "terminal.ansi.{s}", .{n})); pal[i + 8] = z.need(try std.fmt.allocPrint(arena, "terminal.ansi.bright_{s}", .{n})); } try out.append(arena, .{ .name = try slug(arena, (v.get("name") orelse std.process.fatal("{s}: a variant has no `name`", .{path})).string), .bg = z.need("editor.background"), .fg = z.need("editor.foreground"), .tag_bg = z.need("status_bar.background"), .tag_fg = z.need("text.muted"), // zed names its accent, so the move box does not have to be guessed // out of the cursor the way it is for a helix theme .box = z.need("text.accent"), .box_dim = z.need("border"), .kw = z.syn(syntax, "keyword"), .str = z.syn(syntax, "string"), .num = z.syn(syntax, "number"), .comment = z.syn(syntax, "comment"), .lineno = z.need("editor.line_number"), .scroll_track = z.need("scrollbar.thumb.border"), .scroll_thumb = z.need("scrollbar.thumb.background"), .palette = pal, }); } } /// zed's order, which is also the ANSI one: 0-7 plain, 8-15 the `bright_` half const ansi = [8][]const u8{ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white" }; const Zed = struct { path: []const u8, style: std.json.ObjectMap, fn need(z: Zed, key: []const u8) Rgb { const v = z.style.get(key) orelse std.process.fatal("{s}: missing style.{s}", .{ z.path, key }); return switch (v) { .string => |s| hex(z.path, s), else => std.process.fatal("{s}: style.{s} is not a color string", .{ z.path, key }), }; } fn syn(z: Zed, syntax: std.json.ObjectMap, key: []const u8) Rgb { const v = syntax.get(key) orelse std.process.fatal("{s}: missing style.syntax.{s}", .{ z.path, key }); const c = v.object.get("color") orelse std.process.fatal("{s}: style.syntax.{s} has no color", .{ z.path, key }); return switch (c) { .string => |s| hex(z.path, s), else => std.process.fatal("{s}: style.syntax.{s}.color is not a string", .{ z.path, key }), }; } }; /// `#rrggbb`, or zed's `#rrggbbaa`. The alpha is DROPPED: pardes paints opaque /// cells, and the only translucent colors we read are the scrollbar's, where a /// dropped alpha reads as a slightly bolder bar and nothing else. fn hex(path: []const u8, s: []const u8) Rgb { const d = if (s.len > 0 and s[0] == '#') s[1..] else s; if (d.len != 6 and d.len != 8) std.process.fatal("{s}: \"{s}\" is not #rrggbb", .{ path, s }); var out: Rgb = undefined; for (&out, 0..) |*c, i| c.* = std.fmt.parseInt(u8, d[i * 2 ..][0..2], 16) catch std.process.fatal("{s}: \"{s}\" is not #rrggbb", .{ path, s }); return out; }