//! 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 \n\n subject\n",
"commit 0123abc (HEAD -> main)\nAuthor: a \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 } } }});
}