summaryrefslogtreecommitdiff
path: root/src/pager.zig
blob: 304186233321a537455588abd721cfa3f7ddac4e (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
//! A program's output, paged into a +Pager (`pardes -`): the text it
//! prints, and its SGR colours as spans over that text. ghostty-vt, which
//! terminal panes already drive, parses every escape; this keeps what is
//! printed and how SGR styles it, and drops every other control (cursor
//! motion, OSC titles and hyperlinks, modes). The spans are display state
//! of the pane only: no 9P file reads them (File.recolorSyntax draws them).
const std = @import("std");
const pardes = @import("pardes.zig");
const CellStyle = @import("surface.zig").CellStyle;
const ghostty_vt = if (pardes.terminal_panes) @import("ghostty-vt") else struct {};

/// Bytes `start..end` of the text, drawn in `style`. A palette colour stays
/// `.index`, resolved through the theme as it is drawn (terminal.palColor),
/// and `.default` is the pane's own.
pub const Span = struct { start: u32, end: u32, style: CellStyle };

/// ponytail: past this many spans the rest of a page is plain, rather than
/// ~100 MB of spans for a 256 MiB coloured log; a packed style if it matters.
pub const max_spans = 4 << 20;

pub const Page = struct {
    text: []u8,
    spans: []Span,

    pub fn deinit(page: Page, gpa: std.mem.Allocator) void {
        gpa.free(page.text);
        gpa.free(page.spans);
    }
};

/// `raw`, a program's output for a terminal, as text and, `colour` on
/// (PagerColor), its spans. Both are `gpa`'s.
pub fn parse(gpa: std.mem.Allocator, raw: []const u8, colour: bool) !Page {
    // ponytail: no ghostty-vt on esp32p4 (no terminal panes, no PAGER):
    // the text is taken as sent.
    if (comptime !pardes.terminal_panes) return .{ .text = try gpa.dupe(u8, raw), .spans = &.{} };
    var h: Handler = .{ .gpa = gpa, .colour = colour };
    errdefer h.text.deinit(gpa);
    errdefer h.spans.deinit(gpa);
    try h.text.ensureTotalCapacity(gpa, raw.len);
    var stream: ghostty_vt.Stream(*Handler) = .init(&h);
    defer stream.deinit();
    stream.nextSlice(raw);
    if (h.failed) return error.OutOfMemory;
    const text = try h.text.toOwnedSlice(gpa);
    errdefer gpa.free(text);
    return .{ .text = text, .spans = try h.spans.toOwnedSlice(gpa) };
}

const Handler = struct {
    gpa: std.mem.Allocator,
    colour: bool,
    text: std.ArrayList(u8) = .empty,
    spans: std.ArrayList(Span) = .empty,
    /// What SGR has set so far.
    style: CellStyle = .{},
    /// Where the line being printed starts, and its last character.
    line_start: usize = 0,
    last: usize = 0,
    /// A carriage return not yet followed by a newline: the line starts
    /// over at the next print, as on a terminal, so a progress line keeps
    /// its last state.
    cr: bool = false,
    /// A backspace: the next print strikes over the character before it.
    bs: bool = false,
    failed: bool = false,

    const Action = ghostty_vt.StreamAction;

    pub fn vt(h: *Handler, comptime action: Action.Tag, value: Action.Value(action)) void {
        switch (action) {
            .print => h.print(value.cp),
            .print_slice => h.printRun(value.cps),
            // CSI b repeats the last character, as a terminal draws it;
            // a terminal stops at its edge, and this at a line's worth.
            .print_repeat => if (h.text.items.len > h.last) {
                const cp = std.unicode.utf8Decode(h.text.items[h.last..]) catch return;
                for (0..@min(value, 1024)) |_| h.print(cp);
            },
            .set_attribute => if (h.colour) h.attribute(value),
            .linefeed => {
                h.append("\n");
                h.line_start = h.text.items.len;
                h.last = h.line_start;
                h.cr = false;
                h.bs = false;
            },
            .carriage_return => h.cr = true,
            .backspace => h.bs = h.text.items.len > h.line_start,
            .horizontal_tab => for (0..@min(value, 64)) |_| h.print('\t'),
            else => {},
        }
    }

    pub fn deinit(_: *Handler) void {}

    // ponytail: bytes that are not UTF-8 reach here as U+FFFD (ghostty's
    // decoder replaces them, as a terminal shows them); a raw-byte pass
    // through the stream if a +Pager must keep them.
    fn print(h: *Handler, cp: u21) void {
        // A control a terminal acts on and never draws: ghostty's fast path
        // hands those past 0x0f (CAN, DEL, the C1 set) on as printed text.
        // A tab is HT's, printed here.
        if ((cp < 0x20 and cp != '\t') or (cp >= 0x7f and cp < 0xa0)) return;
        if (h.cr) {
            h.text.shrinkRetainingCapacity(h.line_start);
            h.cut(h.line_start);
            h.last = h.line_start;
            h.cr = false;
            h.bs = false;
        }
        var buf: [4]u8 = undefined;
        const n = std.unicode.utf8Encode(cp, &buf) catch std.unicode.utf8Encode(0xfffd, &buf) catch unreachable;
        if (h.bs) {
            // A man page's overstrike: a character struck over itself is
            // bold, one struck with `_` (either way round) is underlined;
            // any other is replaced, as on a terminal.
            h.bs = false;
            const prev = h.text.items[h.last..];
            var struck = h.style;
            if (std.mem.eql(u8, prev, buf[0..n])) {
                struck.bold = true;
            } else if (std.mem.eql(u8, prev, "_")) {
                h.text.shrinkRetainingCapacity(h.last);
                h.append(buf[0..n]);
                struck.ul = .single;
            } else if (cp == '_') {
                struck.ul = .single;
            } else {
                h.text.shrinkRetainingCapacity(h.last);
                h.cut(h.last);
                h.last = h.text.items.len;
                h.append(buf[0..n]);
                h.span(h.last, h.style);
                return;
            }
            h.cut(h.last);
            h.span(h.last, struck);
            return;
        }
        h.last = h.text.items.len;
        h.append(buf[0..n]);
        h.span(h.last, h.style);
    }

    /// A run of printed characters (most of a page): the first as `print`
    /// takes it, a pending return or backspace and all, the rest straight
    /// on, as one span. A 256 MiB page goes through here.
    fn printRun(h: *Handler, cps: []const u32) void {
        if (cps.len == 0) return;
        h.print(@intCast(cps[0]));
        const rest = cps[1..];
        if (rest.len == 0) return;
        var need: usize = 0;
        for (rest) |cp| need += if (cp < 0x80) 1 else std.unicode.utf8CodepointSequenceLength(@intCast(cp)) catch 3;
        h.text.ensureUnusedCapacity(h.gpa, need) catch {
            h.failed = true;
            return;
        };
        const from = h.text.items.len;
        for (rest) |cp| {
            if (cp < 0x20 or (cp >= 0x7f and cp < 0xa0)) continue;
            h.last = h.text.items.len;
            if (cp < 0x80) {
                h.text.appendAssumeCapacity(@intCast(cp));
                continue;
            }
            var buf: [4]u8 = undefined;
            const n = std.unicode.utf8Encode(@intCast(cp), &buf) catch std.unicode.utf8Encode(0xfffd, &buf) catch unreachable;
            h.text.appendSliceAssumeCapacity(buf[0..n]);
        }
        h.span(from, h.style);
    }

    fn append(h: *Handler, bytes: []const u8) void {
        h.text.appendSlice(h.gpa, bytes) catch {
            h.failed = true;
        };
    }

    /// `from` to the text's end drawn in `style`: the span before it goes
    /// on when it ends there in the same style.
    fn span(h: *Handler, from: usize, style: CellStyle) void {
        if (!h.colour or std.meta.eql(style, CellStyle{})) return;
        const end: u32 = @intCast(h.text.items.len);
        if (h.spans.items.len > 0) {
            const tail = &h.spans.items[h.spans.items.len - 1];
            if (tail.end == from and std.meta.eql(tail.style, style)) {
                tail.end = end;
                return;
            }
        }
        if (h.spans.items.len >= max_spans) return;
        h.spans.append(h.gpa, .{ .start = @intCast(from), .end = end, .style = style }) catch {
            h.failed = true;
        };
    }

    /// No span reaches past `at` any more: the text there is gone.
    fn cut(h: *Handler, at: usize) void {
        while (h.spans.items.len > 0) {
            const tail = &h.spans.items[h.spans.items.len - 1];
            if (tail.end <= at) return;
            if (tail.start < at) {
                tail.end = @intCast(at);
                return;
            }
            _ = h.spans.pop();
        }
    }

    fn attribute(h: *Handler, attr: ghostty_vt.Attribute) void {
        const s = &h.style;
        switch (attr) {
            .unset => s.* = .{},
            .bold => s.bold = true,
            // SGR 22 ends faint as well as bold.
            .reset_bold => {
                s.bold = false;
                s.dim = false;
            },
            .faint => s.dim = true,
            .italic => s.italic = true,
            .reset_italic => s.italic = false,
            .underline => |u| s.ul = switch (u) {
                .none => .off,
                .single => .single,
                .double => .double,
                .curly => .curly,
                .dotted => .dotted,
                .dashed => .dashed,
            },
            .blink => s.blink = true,
            .reset_blink => s.blink = false,
            .inverse => s.reverse = true,
            .reset_inverse => s.reverse = false,
            .invisible => s.invisible = true,
            .reset_invisible => s.invisible = false,
            .strikethrough => s.strikethrough = true,
            .reset_strikethrough => s.strikethrough = false,
            .direct_color_fg => |c| s.fg = .{ .rgb = .{ c.r, c.g, c.b } },
            .direct_color_bg => |c| s.bg = .{ .rgb = .{ c.r, c.g, c.b } },
            .@"8_fg", .@"8_bright_fg" => |name| s.fg = .{ .index = @intFromEnum(name) },
            .@"8_bg", .@"8_bright_bg" => |name| s.bg = .{ .index = @intFromEnum(name) },
            .@"256_fg" => |i| s.fg = .{ .index = i },
            .@"256_bg" => |i| s.bg = .{ .index = i },
            .reset_fg => s.fg = .default,
            .reset_bg => s.bg = .default,
            // A cell has no overline and no underline colour to take.
            .overline, .reset_overline, .underline_color, .@"256_underline_color", .reset_underline_color, .unknown => {},
        }
    }
};

// ---- tests ----

const testing = std.testing;

/// The text `raw` pages to, and its spans as `text[start..end]=style` lines.
fn expectPage(raw: []const u8, text: []const u8, spans: []const struct { []const u8, CellStyle }) !void {
    if (comptime !pardes.terminal_panes) return error.SkipZigTest;
    const page = try parse(testing.allocator, raw, true);
    defer page.deinit(testing.allocator);
    try testing.expectEqualStrings(text, page.text);
    for (spans, 0..) |want, i| {
        if (i >= page.spans.len) {
            std.debug.print("missing span {d}: \"{s}\"\n", .{ i, want[0] });
            return error.TestExpectedEqual;
        }
        const got = page.spans[i];
        try testing.expectEqualStrings(want[0], page.text[got.start..got.end]);
        try testing.expectEqualDeep(want[1], got.style);
    }
    try testing.expectEqual(spans.len, page.spans.len);
}

test "git log --color: the commit line yellow, the rest plain" {
    try expectPage(
        "\x1b[33mcommit 0123abc\x1b[m\x1b[33m (\x1b[m\x1b[1;36mHEAD -> \x1b[m\x1b[1;32mmain\x1b[m\x1b[33m)\x1b[m\nAuthor: a <a@x>\n\n    subject\n",
        "commit 0123abc (HEAD -> main)\nAuthor: a <a@x>\n\n    subject\n",
        &.{
            .{ "commit 0123abc (", .{ .fg = .{ .index = 3 } } },
            .{ "HEAD -> ", .{ .fg = .{ .index = 6 }, .bold = true } },
            .{ "main", .{ .fg = .{ .index = 2 }, .bold = true } },
            .{ ")", .{ .fg = .{ .index = 3 } } },
        },
    );
}

test "ls --color=always: 01;34 directories and 01;32 executables, reset by 0 and none" {
    try expectPage(
        "\x1b[0m\x1b[01;34mdir\x1b[0m\n\x1b[01;32mrun.sh\x1b[0m\nplain.txt\n",
        "dir\nrun.sh\nplain.txt\n",
        &.{
            .{ "dir", .{ .fg = .{ .index = 4 }, .bold = true } },
            .{ "run.sh", .{ .fg = .{ .index = 2 }, .bold = true } },
        },
    );
}

test "grep --color=always: the match, the file name and the separator, and EL dropped" {
    try expectPage(
        "\x1b[35m\x1b[Ka.txt\x1b[m\x1b[K\x1b[36m\x1b[K:\x1b[m\x1b[Kone \x1b[01;31m\x1b[Kfoo\x1b[m\x1b[K two\n",
        "a.txt:one foo two\n",
        &.{
            .{ "a.txt", .{ .fg = .{ .index = 5 } } },
            .{ ":", .{ .fg = .{ .index = 6 } } },
            .{ "foo", .{ .fg = .{ .index = 1 }, .bold = true } },
        },
    );
}

test "a man page's overstrike: bold and underline, and a stray backspace" {
    try expectPage(
        "N\x08NA\x08AM\x08ME\x08E _\x08u_\x08s x\x08_ a\x08b\x08\n",
        "NAME us x b\n",
        &.{
            .{ "NAME", .{ .bold = true } },
            .{ "us", .{ .ul = .single } },
            .{ "x", .{ .ul = .single } },
        },
    );
}

test "truecolor, 256 colours, background, italic, faint, inverse and curly underline" {
    try expectPage(
        "\x1b[38;2;255;128;0mo\x1b[48;5;236mb\x1b[0;3;2mi\x1b[7mr\x1b[0;4:3mc\x1b[m.\n",
        "obirc.\n",
        &.{
            .{ "o", .{ .fg = .{ .rgb = .{ 255, 128, 0 } } } },
            .{ "b", .{ .fg = .{ .rgb = .{ 255, 128, 0 } }, .bg = .{ .index = 236 } } },
            .{ "i", .{ .italic = true, .dim = true } },
            .{ "r", .{ .italic = true, .dim = true, .reverse = true } },
            .{ "c", .{ .ul = .curly } },
        },
    );
}

test "every other escape is dropped, and a stray or broken one loses no text after it" {
    const cases = [_][2][]const u8{
        .{ "a\x1b]8;;http://x\x1b\\link\x1b]8;;\x07b", "alinkb" },
        .{ "\x1b]0;title\x07\x1b[2J\x1b[H\x1b[?1049hdone\n", "done\n" },
        .{ "\x1b(Bdone", "done" },
        .{ "plain\ttext\n", "plain\ttext\n" },
        .{ "a\r\nb\r\n", "a\nb\n" },
        .{ "10%\r50%\r\x1b[K100%\ndone\n", "100%\ndone\n" },
        .{ "x\x00y\x07z\x0e\x0f\n", "xyz\n" },
        // Controls ghostty's fast path prints: CAN, FS, DEL and a C1.
        .{ "x\x18y\x1cz\x7f!\u{85}?\n", "xyz!?\n" },
        // A broken CSI: its parameter bytes are eaten up to a final byte, as
        // a terminal eats them; CAN abandons one, and the text goes on.
        .{ "a\x1b[31;\x18b\x1b[m\n", "ab\n" },
        // An unknown SGR changes nothing; an ESC at the very end is dropped.
        .{ "\x1b[99mz\x1b", "z" },
    };
    for (cases) |c| try expectPage(c[0], c[1], &.{});
}

test "bytes that are not UTF-8 page as U+FFFD, the text around them kept" {
    try expectPage("caf\xe9 \xff\xfe ok\n", "caf\u{fffd} \u{fffd}\u{fffd} ok\n", &.{});
}

test "PagerColor off: the same text, no spans, overstrike included" {
    if (comptime !pardes.terminal_panes) return error.SkipZigTest;
    const page = try parse(testing.allocator, "\x1b[31mred\x1b[m N\x08N\n", false);
    defer page.deinit(testing.allocator);
    try testing.expectEqualStrings("red N\n", page.text);
    try testing.expectEqual(@as(usize, 0), page.spans.len);
}

test "a carriage return's rewrite takes the spans of what it rewrote with it" {
    try expectPage("\x1b[31m10%\r\x1b[32m100%\x1b[m\n", "100%\n", &.{.{ "100%", .{ .fg = .{ .index = 2 } } }});
}