summaryrefslogtreecommitdiff
path: root/tools/gen_themes.zig
blob: 50c32b8bb26e7a58814eb7836f942b28cf19c91e (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
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! 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 `<name>.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 <out-dir> <theme-source>...", .{});

    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 <name>` 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;
}