// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Describing commits as `git log --pretty` does: the built-in formats //! (`oneline`, `short`, `medium`, `full`, `fuller`, `reference`, `email`, //! `mboxrd`, `raw`) and `format:`, `tformat:` and bare `%` strings, read //! with no I/O (`parse`) and written to an `Io.Writer` (`write`), as //! git-log(1)'s "PRETTY FORMATS" describes them and as git was seen to //! write them where it does not say. //! //! **Placeholders.** Each one git-log(1) lists is here except those that //! need what this library does not have yet — notes (`%N`), signatures //! (`%G?` and the rest) and reflogs (`%g*`) — which `parse` refuses by //! name (`UnsupportedPlaceholder`). Decorations (`%d`, `%D`, //! `%(decorate)`), `%S` and `%(describe)` write what the caller found and //! put in `Shown`; `%(decorate:...)` reads its options as git was seen to: //! `key=value` pairs separated by commas up to the first `)`, a key alone //! taking an empty value and a trailing comma allowed, each value with //! `%n`, `%%` and `%xNN` expanded and ending at a NUL; an unknown key, an //! empty option or no `)` make the placeholder one git does not know. A placeholder git does not know is written //! as it is, `%` and all; after a `+`, `-` or space it is written without //! the `%` and that character. //! //! **The message**: `%s` is the first paragraph after any blank lines, its //! lines with the blank space at their ends removed and joined with //! spaces; `%b` everything after it and the blank lines that follow it, as //! it is; `%B` the whole message as it is; and `%f` the subject with each //! run of other bytes, those past ASCII among them, made one `-`, runs of //! `.` made one, and no `-` or `.` at its end or `-` at its start. //! //! **Tabs** in the message of `medium`, `full` and `fuller` are expanded //! to the next multiple of eight columns, until a stretch before a tab //! holds a control character (an ESC among them) or bytes that are not //! UTF-8, which have no width: that stretch and the rest of the line are //! written as they are. //! //! **Identities**: an identity line git cannot read shows nothing for //! `%an`, `%ae`, `%ad`, `%at`, `%aD`, `%ar` and `%ai`, and is written as it //! is for the others; one without a readable date does the same for the //! date placeholders. `%aN`, `%aE` and `%aL` are mapped through the //! mailmap, and so are the built-in formats' identities with //! `--use-mailmap`. //! //! **Padding** (`%<(N)`, `%>(N)`, `%><(N)`, `%>>(N)` and their `|` //! forms): the next placeholder that writes anything — not text, `%%` or a //! color — is padded with spaces to `N` columns, or to column `N` (from //! the terminal's right edge when negative), and truncated with `..` at //! its end, start or middle when asked; columns are counted as `width` //! counts them, color escapes taking none. //! //! **Wrapping** (`%w(width,indent1,indent2)`) applies to what is written //! from it to the next `%w` or the end, as a unit: the first line is //! indented `indent1` spaces and every other `indent2`, and a line is //! broken at the last blank before a word that would pass `width` //! columns; a line feed before a letter or digit joins its lines with a //! space, two make a paragraph, and one before anything else is kept, the //! blank after it dropped. Tabs move to the next multiple of eight, //! counting the indentation, and other control characters count as one //! column fewer, as git's own reckoning has them; in text that is not //! UTF-8, every byte but a tab or a color escape counts as one. //! //! **Color**: `%C()`, `%Cred`, `%Cgreen`, `%Cblue` and `%Creset` //! write escape sequences when color is on, `%C(always,)` whether //! or not, and `%C(auto)` turns on the default coloring of `%H` and `%h` //! until the next color, after writing a reset if anything has been //! written. A spec is read only when it is written, and one git refuses //! is an error then (`InvalidColor`). //! //! **`email`** writes a name or subject holding a byte past ASCII or an //! ESC (as ISO-2022-JP's escape sequences do; no other control character //! counts) as RFC 2047 encoded words, folded at 76 columns, quotes a name //! holding RFC 5322's specials, folds any other name or subject at 78 //! columns as `%w(78,0,1)` would with a break allowed before its first //! word, and adds MIME headers, `Content-Transfer-Encoding: 8bit`, when //! the subject or body holds such a byte; `git rev-list` encodes nothing, //! and folds and quotes every name and subject. //! //! **Encodings**: a built-in format writes the commit re-encoded into //! `Settings.output_encoding` as `encoding.reencodeCommit` says, before //! anything is read from it. A format of the user's, and `reference`, //! which is one of git's, writes the commit re-encoded into UTF-8, and //! its whole output is then converted into the output encoding, and left //! in UTF-8 where that fails, as git was seen to do: a character of the //! format's own is converted with the rest. What `encoding` does not //! reproduce is refused (`UnsupportedEncoding`). ISO-2022-JP, written by //! either conversion, is left in the mode of its last character, as //! `encoding.convert` says, so a format of the user's ending in JIS X 0208 //! has no escape back to ASCII before the line feed `tformat:` adds or the //! NUL `-z` does. `%e` is the commit's own //! `encoding` header. `email` names the output encoding as it was given //! in its encoded words and its `Content-Type`. const std = @import("std"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const object = @import("object.zig"); const Id = object.Id; const Commit = @import("commit.zig").Commit; const Identity = @import("Identity.zig"); const date = @import("date.zig"); const Mailmap = @import("Mailmap.zig"); const encoding = @import("encoding.zig"); const trailers = @import("trailers.zig"); const width = @import("width.zig"); const Diagnostics = @import("Diagnostics.zig"); const Decorations = @import("Decorations.zig"); /// The built-in formats. pub const Builtin = enum { oneline, short, medium, full, fuller, reference, email, mboxrd, raw, /// Whether the format indents the message by four spaces, expanding /// tabs by default. fn indented(b: Builtin) bool { return switch (b) { .medium, .full, .fuller => true, else => false, }; } }; /// A format: one of the built-in ones, or a format string. pub const Format = union(enum) { builtin: Builtin, user: User, /// Frees a format string's parts. pub fn deinit(f: *Format) void { switch (f.*) { .builtin => {}, .user => |*u| u.arena.deinit(), } f.* = undefined; } /// Whether every commit's output ends with a line feed (`tformat:`, or /// a bare `%` string), rather than one separating commits (`format:`). pub fn terminated(f: *const Format) bool { return switch (f.*) { .builtin => true, .user => |u| u.terminated, }; } }; /// A format string, read. pub const User = struct { arena: std.heap.ArenaAllocator, items: []const Item, /// `tformat:` rather than `format:`. terminated: bool, /// Whether the format string is empty (`--format=`, `format:`). git /// writes nothing at all for a commit then, not even the line feed /// ending it under `tformat:`, and no separator before its diff; /// `format:` still writes its line feed between commits. A string /// that merely expands to nothing, such as `%(trailers)` of a commit /// with none, is not empty. empty: bool = false, /// Whether it writes `%P` or `%p`, which needs a commit's parents /// rewritten in a history limited to paths. uses_parents: bool = false, /// Whether it writes anything of the message or the identities, which /// git would write in its output encoding. reads_text: bool = false, /// What each `%(describe...)` asks for, in order; a caller describes /// each commit so for `Shown.descriptions`. describes: []const DescribeOptions = &.{}, /// Whether it writes decorations, which a caller then has to find. pub fn usesDecorations(u: *const User) bool { for (u.items) |item| if (item.kind == .decorate) return true; return false; } /// Whether it writes `%S`. pub fn usesSource(u: *const User) bool { for (u.items) |item| if (item.kind == .source) return true; return false; } }; /// What a `%(describe:...)` asks of `git describe`. pub const DescribeOptions = struct { /// `tags`: lightweight tags too. tags: bool = false, /// `abbrev=`. abbrev: ?usize = null, /// `match=`, each. match: []const []const u8 = &.{}, /// `exclude=`, each. exclude: []const []const u8 = &.{}, }; /// One piece of a format string. pub const Item = struct { kind: Kind, magic: Magic = .none, /// The text it was read from: the whole placeholder, `%` included, or /// the literal text. text: []const u8, }; /// What `+`, `-` or a space after the `%` asks of a placeholder. pub const Magic = enum { none, /// A line feed before it, if it writes anything. plus, /// The line feeds before it removed, if it writes nothing. minus, /// A space before it, if it writes anything. space, }; /// What a piece of a format string is. pub const Kind = union(enum) { /// Text written as it is. literal, /// `%%`. percent, /// `%n`. newline, /// `%xNN`. byte: u8, color: Color, padding: Padding, wrap: Wrap, field: Field, person: Person, trailers: trailers.Options, /// `%d`, `%D` and `%(decorate...)`. decorate: DecorateFormat, /// `%S`. source, /// `%(describe...)`: which of the caller's descriptions to write. describe: u32, /// A placeholder git does not know, written as it is. unknown, }; /// How decorations are written: what `%(decorate:...)` can change. pub const DecorateFormat = struct { prefix: []const u8 = " (", suffix: []const u8 = ")", separator: []const u8 = ", ", pointer: []const u8 = " -> ", tag: []const u8 = "tag: ", }; /// A color placeholder. pub const Color = union(enum) { /// `%C(auto)`. auto, /// A color spec, as git-config(1)'s "color" type reads one. spec: struct { text: []const u8, /// `%C(always,...)`: written even when color is off. always: bool = false, }, }; /// `%<(N)` and its relatives. pub const Padding = struct { align_to: enum { left, right, center }, /// `%>>(N)`: take spaces from the left when the text is too wide — /// at most as many as were written just before the placeholder. steal: bool = false, /// The `|` forms: `columns` is a column, from the right edge when /// negative, rather than a width. column: bool = false, columns: i64, truncate: enum { none, end, start, middle } = .none, }; /// `%w(...)`. pub const Wrap = struct { width: i64 = 0, indent1: i64 = 0, indent2: i64 = 0, }; /// A placeholder for something about the commit. pub const Field = enum { commit, commit_abbrev, tree, tree_abbrev, parents, parents_abbrev, subject, sanitized_subject, body, raw_body, encoding, mark, }; /// `%a` or `%c`. pub const Person = struct { committer: bool, /// The letter after `a` or `c`. what: u8, }; /// What `parse` can fail with: a name that is neither a built-in format /// nor a format string, and placeholders this library does not support, /// which the diagnostics name. pub const ParseError = error{ UnknownFormat, UnsupportedPlaceholder } || Allocator.Error; const builtin_names = [_]struct { []const u8, Builtin }{ .{ "oneline", .oneline }, .{ "short", .short }, .{ "medium", .medium }, .{ "full", .full }, .{ "fuller", .fuller }, .{ "reference", .reference }, .{ "email", .email }, .{ "mboxrd", .mboxrd }, .{ "raw", .raw }, }; /// The built-in format `name` names, if any. pub fn builtinNamed(name: []const u8) ?Builtin { for (builtin_names) |b| if (std.mem.eql(u8, b[0], name)) return b[1]; return null; } /// Looks up `pretty.` aliases, for `resolve`. pub const Aliases = struct { context: *const anyopaque, get: *const fn (context: *const anyopaque, name: []const u8) ?[]const u8, }; /// What `--pretty=` means once `pretty.` aliases are followed: /// the text of a built-in name, `format:`/`tformat:` string or bare `%` /// string. An alias naming itself, directly or not, is the format git /// refuses as unknown. pub fn resolve(text: []const u8, aliases: ?Aliases) []const u8 { var current = text; var depth: usize = 0; while (depth < 64) : (depth += 1) { if (builtinNamed(current) != null) return current; if (std.mem.startsWith(u8, current, "format:") or std.mem.startsWith(u8, current, "tformat:")) return current; if (std.mem.findScalar(u8, current, '%') != null) return current; const a = aliases orelse return current; current = a.get(a.context, current) orelse return current; } return current; } /// Reads `--pretty=` (or `--format=`), aliases already /// followed. pub fn parse(gpa: Allocator, text: []const u8, diag: ?*Diagnostics) ParseError!Format { if (builtinNamed(text)) |b| return .{ .builtin = b }; if (std.mem.startsWith(u8, text, "format:")) return .{ .user = try parseUser(gpa, text["format:".len..], false, diag) }; if (std.mem.startsWith(u8, text, "tformat:")) return .{ .user = try parseUser(gpa, text["tformat:".len..], true, diag) }; if (std.mem.findScalar(u8, text, '%') != null or text.len == 0) return .{ .user = try parseUser(gpa, text, true, diag) }; if (diag) |d| d.setDetail("invalid --pretty format: {s}", .{text}); return error.UnknownFormat; } /// Reads a format string. pub fn parseUser(gpa: Allocator, text: []const u8, terminated: bool, diag: ?*Diagnostics) ParseError!User { var arena: std.heap.ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); var items: std.ArrayList(Item) = .empty; var uses_parents = false; var reads_text = false; var describes: std.ArrayList(DescribeOptions) = .empty; var i: usize = 0; var literal_start: usize = 0; while (i < text.len) { if (text[i] != '%') { i += 1; continue; } if (i > literal_start) try items.append(a, .{ .kind = .literal, .text = text[literal_start..i] }); const start = i; i += 1; var magic: Magic = .none; if (i < text.len) switch (text[i]) { '+' => magic = .plus, '-' => magic = .minus, ' ' => magic = .space, else => {}, }; if (magic != .none) i += 1; const rest = text[i..]; const read = try readPlaceholder(a, rest, diag, &describes); i += read.len; if (read.kind == .field and (read.kind.field == .parents or read.kind.field == .parents_abbrev)) uses_parents = true; switch (read.kind) { .person, .trailers => reads_text = true, .field => |f| switch (f) { .subject, .sanitized_subject, .body, .raw_body => reads_text = true, else => {}, }, else => {}, } try items.append(a, .{ .kind = read.kind, .magic = magic, .text = text[start..i] }); literal_start = i; } if (literal_start < text.len) try items.append(a, .{ .kind = .literal, .text = text[literal_start..] }); return .{ .arena = arena, .items = items.items, .terminated = terminated, .empty = text.len == 0, .uses_parents = uses_parents, .reads_text = reads_text, .describes = describes.items }; } const Read = struct { kind: Kind, len: usize }; fn unsupported(diag: ?*Diagnostics, name: []const u8) ParseError { if (diag) |d| d.setDetail("{s}", .{name}); return error.UnsupportedPlaceholder; } /// `%(decorate)` or `%(decorate:)`. fn readDecorate(a: Allocator, rest: []const u8) Allocator.Error!Read { const unknown: Read = .{ .kind = .unknown, .len = 0 }; const close = std.mem.findScalar(u8, rest, ')') orelse return unknown; const inside = rest["(decorate".len..close]; var f: DecorateFormat = .{}; if (inside.len != 0) { if (inside[0] != ':') return unknown; var options = inside[1..]; while (options.len != 0) { const comma = std.mem.findScalar(u8, options, ','); const option = options[0 .. comma orelse options.len]; options = if (comma) |c| options[c + 1 ..] else ""; const eq = std.mem.findScalar(u8, option, '='); const key = option[0 .. eq orelse option.len]; const value = try expandOptionValue(a, if (eq) |e| option[e + 1 ..] else ""); if (std.mem.eql(u8, key, "prefix")) { f.prefix = value; } else if (std.mem.eql(u8, key, "suffix")) { f.suffix = value; } else if (std.mem.eql(u8, key, "separator")) { f.separator = value; } else if (std.mem.eql(u8, key, "pointer")) { f.pointer = value; } else if (std.mem.eql(u8, key, "tag")) { f.tag = value; } else return unknown; } } return .{ .kind = .{ .decorate = f }, .len = close + 1 }; } /// An option's value with `%n`, `%%` and `%xNN` expanded, as far as the /// first NUL. git reads the value as a C string, so a NUL ends it whether /// it was written `%x00` or is a NUL byte in the format itself. fn expandOptionValue(a: Allocator, text: []const u8) Allocator.Error![]const u8 { var out: std.ArrayList(u8) = .empty; var i: usize = 0; while (i < text.len) { if (text[i] == 0) break; if (text[i] == '%' and i + 1 < text.len) { const c = text[i + 1]; if (c == 'n') { try out.append(a, '\n'); i += 2; continue; } if (c == '%') { try out.append(a, '%'); i += 2; continue; } if (c == 'x' and i + 3 < text.len and std.ascii.isHex(text[i + 2]) and std.ascii.isHex(text[i + 3])) { const b = std.fmt.parseInt(u8, text[i + 2 .. i + 4], 16) catch unreachable; if (b == 0) break; try out.append(a, b); i += 4; continue; } } try out.append(a, text[i]); i += 1; } return out.items; } /// `%(describe)` or `%(describe:)`, its options added to /// `describes`. fn readDescribe(a: Allocator, rest: []const u8, describes: *std.ArrayList(DescribeOptions)) Allocator.Error!Read { const unknown: Read = .{ .kind = .unknown, .len = 0 }; const close = std.mem.findScalar(u8, rest, ')') orelse return unknown; const inside = rest["(describe".len..close]; var d: DescribeOptions = .{}; var matches: std.ArrayList([]const u8) = .empty; var excludes: std.ArrayList([]const u8) = .empty; if (inside.len != 0) { if (inside[0] != ':') return unknown; var options = inside[1..]; while (options.len != 0) { const comma = std.mem.findScalar(u8, options, ','); const option = options[0 .. comma orelse options.len]; options = if (comma) |c| options[c + 1 ..] else ""; const eq = std.mem.findScalar(u8, option, '='); const key = option[0 .. eq orelse option.len]; const value: ?[]const u8 = if (eq) |e| try expandOptionValue(a, option[e + 1 ..]) else null; if (std.mem.eql(u8, key, "tags")) { // A value that is not a boolean is false. d.tags = if (value) |v| parseBoolText(v) orelse false else true; } else if (std.mem.eql(u8, key, "abbrev")) { const v = value orelse return unknown; const n = strtol(v) orelse return unknown; if (n.end != v.len or v.len == 0) return unknown; d.abbrev = if (n.value == 0) 0 else if (n.value < 4) 4 else @intCast(@min(n.value, 64)); } else if (std.mem.eql(u8, key, "match")) { try matches.append(a, value orelse return unknown); } else if (std.mem.eql(u8, key, "exclude")) { try excludes.append(a, value orelse return unknown); } else return unknown; } } d.match = matches.items; d.exclude = excludes.items; try describes.append(a, d); return .{ .kind = .{ .describe = @intCast(describes.items.len - 1) }, .len = close + 1 }; } fn parseBoolText(v: []const u8) ?bool { const yes = [_][]const u8{ "true", "yes", "on", "1" }; const no = [_][]const u8{ "false", "no", "off", "0", "" }; for (yes) |y| if (std.ascii.eqlIgnoreCase(v, y)) return true; for (no) |n| if (std.ascii.eqlIgnoreCase(v, n)) return false; return null; } fn readPlaceholder(a: Allocator, rest: []const u8, diag: ?*Diagnostics, describes: *std.ArrayList(DescribeOptions)) ParseError!Read { if (rest.len == 0) return .{ .kind = .unknown, .len = 0 }; const c = rest[0]; switch (c) { '%' => return .{ .kind = .percent, .len = 1 }, 'n' => return .{ .kind = .newline, .len = 1 }, 'H' => return .{ .kind = .{ .field = .commit }, .len = 1 }, 'h' => return .{ .kind = .{ .field = .commit_abbrev }, .len = 1 }, 'T' => return .{ .kind = .{ .field = .tree }, .len = 1 }, 't' => return .{ .kind = .{ .field = .tree_abbrev }, .len = 1 }, 'P' => return .{ .kind = .{ .field = .parents }, .len = 1 }, 'p' => return .{ .kind = .{ .field = .parents_abbrev }, .len = 1 }, 's' => return .{ .kind = .{ .field = .subject }, .len = 1 }, 'f' => return .{ .kind = .{ .field = .sanitized_subject }, .len = 1 }, 'b' => return .{ .kind = .{ .field = .body }, .len = 1 }, 'B' => return .{ .kind = .{ .field = .raw_body }, .len = 1 }, 'e' => return .{ .kind = .{ .field = .encoding }, .len = 1 }, 'm' => return .{ .kind = .{ .field = .mark }, .len = 1 }, 'd' => return .{ .kind = .{ .decorate = .{} }, .len = 1 }, 'D' => return .{ .kind = .{ .decorate = .{ .prefix = "", .suffix = "" } }, .len = 1 }, 'S' => return .{ .kind = .source, .len = 1 }, 'N' => return unsupported(diag, rest[0..1]), 'G' => { if (rest.len >= 2 and std.mem.findScalar(u8, "?GSKFPT", rest[1]) != null) return unsupported(diag, rest[0..2]); return .{ .kind = .unknown, .len = 0 }; }, 'g' => { if (rest.len >= 2 and std.mem.findScalar(u8, "DdnNeEs", rest[1]) != null) return unsupported(diag, rest[0..2]); return .{ .kind = .unknown, .len = 0 }; }, 'a', 'c' => { if (rest.len >= 2 and std.mem.findScalar(u8, "nNeElLdDrtiIsh", rest[1]) != null) return .{ .kind = .{ .person = .{ .committer = c == 'c', .what = rest[1] } }, .len = 2 }; return .{ .kind = .unknown, .len = 0 }; }, 'x' => { if (rest.len >= 3 and std.ascii.isHex(rest[1]) and std.ascii.isHex(rest[2])) return .{ .kind = .{ .byte = std.fmt.parseInt(u8, rest[1..3], 16) catch unreachable }, .len = 3 }; return .{ .kind = .unknown, .len = 0 }; }, 'C' => return readColor(rest), 'w' => return readWrap(rest), '<', '>' => return readPadding(rest), '(' => { if (std.mem.startsWith(u8, rest, "(decorate")) return readDecorate(a, rest); if (std.mem.startsWith(u8, rest, "(describe")) return readDescribe(a, rest, describes); if (std.mem.startsWith(u8, rest, "(trailers")) return readTrailers(a, rest); return .{ .kind = .unknown, .len = 0 }; }, else => return .{ .kind = .unknown, .len = 0 }, } } fn readColor(rest: []const u8) Read { const named = [_]struct { []const u8, []const u8 }{ .{ "Cred", "red" }, .{ "Cgreen", "green" }, .{ "Cblue", "blue" }, .{ "Creset", "reset" }, }; for (named) |n| if (std.mem.startsWith(u8, rest, n[0])) return .{ .kind = .{ .color = .{ .spec = .{ .text = n[1] } } }, .len = n[0].len }; if (rest.len < 2 or rest[1] != '(') return .{ .kind = .unknown, .len = 0 }; const close = std.mem.findScalar(u8, rest, ')') orelse return .{ .kind = .unknown, .len = 0 }; var spec = rest[2..close]; const len = close + 1; if (std.mem.eql(u8, spec, "auto")) return .{ .kind = .{ .color = .auto }, .len = len }; var always = false; if (std.mem.startsWith(u8, spec, "always,")) { always = true; spec = spec["always,".len..]; } else if (std.mem.startsWith(u8, spec, "auto,")) { spec = spec["auto,".len..]; } return .{ .kind = .{ .color = .{ .spec = .{ .text = spec, .always = always } } }, .len = len }; } /// A number as `strtol` reads it: blank space, a sign, digits; and where /// it stopped. fn strtol(text: []const u8) ?struct { value: i64, end: usize } { var i: usize = 0; while (i < text.len and (text[i] == ' ' or (text[i] >= '\t' and text[i] <= '\r'))) i += 1; var negative = false; if (i < text.len and (text[i] == '+' or text[i] == '-')) { negative = text[i] == '-'; i += 1; } const digits = i; var v: i64 = 0; while (i < text.len and std.ascii.isDigit(text[i])) : (i += 1) { v = std.math.mul(i64, v, 10) catch std.math.maxInt(i64); v = std.math.add(i64, v, text[i] - '0') catch std.math.maxInt(i64); } if (i == digits) return null; return .{ .value = if (negative) -v else v, .end = i }; } fn readWrap(rest: []const u8) Read { if (rest.len < 2 or rest[1] != '(') return .{ .kind = .unknown, .len = 0 }; const close = std.mem.findScalar(u8, rest, ')') orelse return .{ .kind = .unknown, .len = 0 }; const args = rest[2..close]; var values = [3]i64{ 0, 0, 0 }; var it = std.mem.splitScalar(u8, args, ','); var n: usize = 0; while (it.next()) |part| : (n += 1) { if (n == 3) return .{ .kind = .unknown, .len = 0 }; if (part.len == 0 and args.len == 0) break; const v = strtol(part) orelse return .{ .kind = .unknown, .len = 0 }; if (v.end != part.len) return .{ .kind = .unknown, .len = 0 }; if (v.value < 0) return .{ .kind = .unknown, .len = 0 }; values[n] = v.value; } return .{ .kind = .{ .wrap = .{ .width = values[0], .indent1 = values[1], .indent2 = values[2] } }, .len = close + 1 }; } fn readPadding(rest: []const u8) Read { var p: Padding = .{ .align_to = .left, .columns = 0 }; var i: usize = 1; if (rest[0] == '<') { p.align_to = .left; } else if (i < rest.len and rest[i] == '<') { p.align_to = .center; i += 1; } else if (i < rest.len and rest[i] == '>') { p.align_to = .right; p.steal = true; i += 1; } else p.align_to = .right; if (i < rest.len and rest[i] == '|') { p.column = true; i += 1; } if (i >= rest.len or rest[i] != '(') return .{ .kind = .unknown, .len = 0 }; i += 1; const n = strtol(rest[i..]) orelse return .{ .kind = .unknown, .len = 0 }; i += n.end; while (i < rest.len and rest[i] == ' ') i += 1; if (i >= rest.len) return .{ .kind = .unknown, .len = 0 }; if (rest[i] == ',') { i += 1; const modes = [_]struct { []const u8, @TypeOf(p.truncate) }{ .{ "trunc)", .end }, .{ "ltrunc)", .start }, .{ "mtrunc)", .middle } }; for (modes) |m| { if (std.mem.startsWith(u8, rest[i..], m[0])) { p.truncate = m[1]; i += m[0].len; break; } } else return .{ .kind = .unknown, .len = 0 }; } else if (rest[i] == ')') { i += 1; } else return .{ .kind = .unknown, .len = 0 }; if (p.column) { if (n.value == 0) return .{ .kind = .unknown, .len = 0 }; } else if (n.value <= 0) return .{ .kind = .unknown, .len = 0 }; p.columns = n.value; return .{ .kind = .{ .padding = p }, .len = i }; } /// Reads a boolean option's value as git-config(1)'s "bool" type does. fn parseBool(text: []const u8) ?bool { const truths = [_][]const u8{ "true", "yes", "on", "1" }; const lies = [_][]const u8{ "false", "no", "off", "0", "" }; for (truths) |t| if (std.ascii.eqlIgnoreCase(t, text)) return true; for (lies) |l| if (std.ascii.eqlIgnoreCase(l, text)) return false; const n = std.fmt.parseInt(i64, text, 10) catch return null; return n != 0; } /// Expands `%xNN`, `%n` and `%%` in an option value, as git does for /// `separator=` and `key_value_separator=`. Unlike a decoration option's /// value, git keeps a NUL here, `%x00` and all, and writes it between the /// trailers; a NUL byte in the format is kept the same way. fn expandLiteral(a: Allocator, text: []const u8) Allocator.Error![]const u8 { var out: std.ArrayList(u8) = .empty; var i: usize = 0; while (i < text.len) { if (text[i] == '%' and i + 1 < text.len) { const next_char = text[i + 1]; if (next_char == 'n') { try out.append(a, '\n'); i += 2; continue; } if (next_char == '%') { try out.append(a, '%'); i += 2; continue; } if (next_char == 'x' and i + 3 < text.len and std.ascii.isHex(text[i + 2]) and std.ascii.isHex(text[i + 3])) { try out.append(a, std.fmt.parseInt(u8, text[i + 2 .. i + 4], 16) catch unreachable); i += 4; continue; } } try out.append(a, text[i]); i += 1; } return out.items; } fn readTrailers(a: Allocator, rest: []const u8) Allocator.Error!Read { const name = "(trailers"; var i = name.len; if (i < rest.len and rest[i] == ')') return .{ .kind = .{ .trailers = .{} }, .len = i + 1 }; if (i >= rest.len or rest[i] != ':') return .{ .kind = .unknown, .len = 0 }; i += 1; var o: trailers.Options = .{}; var keys: std.ArrayList([]const u8) = .empty; while (true) { if (i >= rest.len) return .{ .kind = .unknown, .len = 0 }; if (rest[i] == ')') { i += 1; break; } const end = std.mem.findAnyPos(u8, rest, i, ",)") orelse return .{ .kind = .unknown, .len = 0 }; const option = rest[i..end]; i = end; if (rest[i] == ',') i += 1; const eq = std.mem.findScalar(u8, option, '='); const key = option[0 .. eq orelse option.len]; const value: ?[]const u8 = if (eq) |e| option[e + 1 ..] else null; const Flag = enum { only, unfold, keyonly, valueonly }; if (std.meta.stringToEnum(Flag, key)) |flag| { const b = if (value) |v| parseBool(v) orelse return .{ .kind = .unknown, .len = 0 } else true; switch (flag) { .only => o.only = b, .unfold => o.unfold = b, .keyonly => o.key_only = b, .valueonly => o.value_only = b, } } else if (std.mem.eql(u8, key, "key") and value != null) { var k = value.?; if (k.len == 0) { try keys.append(a, "\x00"); } else { if (k[k.len - 1] == ':') k = k[0 .. k.len - 1]; try keys.append(a, k); } o.only = true; } else if (std.mem.eql(u8, key, "separator") and value != null) { o.separator = try expandLiteral(a, value.?); } else if (std.mem.eql(u8, key, "key_value_separator") and value != null) { o.key_value_separator = try expandLiteral(a, value.?); } else if (option.len == 0 and rest[i - 1] != ',') { // `%(trailers:)`. } else return .{ .kind = .unknown, .len = 0 }; } o.keys = keys.items; return .{ .kind = .{ .trailers = o }, .len = i }; } // -- writing ------------------------------------------------------------------ /// Which side of a symmetric difference, or the boundary, a commit is on, /// as `%m` and `--left-right` show it. pub const Mark = enum { none, left, right, boundary }; /// A commit to describe. pub const Shown = struct { id: Id, /// The commit's contents. contents: []const u8, /// Its parents as the walk shows them, rewritten in a history limited /// to paths. parents: []const Id, /// Its children, for `--children`. children: []const Id = &.{}, mark: Mark = .none, /// Its decorations, in the order they are shown. decorations: []const Decorations.Decoration = &.{}, /// What `--source` and `%S` say it was reached from. source: ?[]const u8 = null, /// What each of the format's `%(describe...)` makes of it, in order; /// null or empty for a commit nothing describes. descriptions: []const ?[]const u8 = &.{}, /// The parent a merge is being shown against, one parent at a time /// (`-m`): the first line says `(from )` after the commit. from: ?Id = null, }; /// The colors decorations are written in, each an escape sequence, as /// `color.decorate.` sets them. pub const DecorationColors = struct { head: []const u8 = "\x1b[1;36m", branch: []const u8 = "\x1b[1;32m", remote_branch: []const u8 = "\x1b[1;31m", tag: []const u8 = "\x1b[1;33m", stash: []const u8 = "\x1b[1;35m", ref: []const u8 = "\x1b[m", grafted: []const u8 = "\x1b[1;34m", /// The color of a kind of decoration; replaced objects take the /// grafted commits' color. pub fn of(c: DecorationColors, kind: Decorations.Kind) []const u8 { return switch (kind) { .head => c.head, .branch => c.branch, .remote_branch => c.remote_branch, .tag => c.tag, .stash => c.stash, .ref => c.ref, .grafted, .replaced => c.grafted, }; } }; /// Shortens object IDs, as the object database can tell how many digits /// are needed. pub const Abbreviator = struct { context: *anyopaque, shorten: *const fn (context: *anyopaque, w: *Writer, id: Id, length: usize) ShortenError!void, /// The fewest digits, `--abbrev` or `core.abbrev`. length: usize, }; /// What shortening an object ID can fail with. pub const ShortenError = Writer.Error || error{ShortenFailed}; /// How commits are described, besides the format. pub const Settings = struct { abbreviator: Abbreviator, /// The encoding commits are written in: `--encoding`, /// `i18n.logOutputEncoding` or `i18n.commitEncoding`. output_encoding: []const u8 = "UTF-8", /// `--abbrev-commit`: the built-in formats' commit IDs shortened. abbrev_commit: bool = false, /// `--no-abbrev`: the `Merge:` line's object IDs written whole. full_merge_ids: bool = false, /// The `--date=` mode. date_mode: date.Mode = .{}, /// Whether `--date=` was given, which `reference` follows only then. date_given: bool = false, /// The current time, for relative and human dates. now: i64 = 0, /// The local zone. zone: *const date.Zone = &date.Zone.utc, /// The mailmap, for `%aN` and the rest. mailmap: ?*const Mailmap = null, /// `--use-mailmap`: the built-in formats' identities mapped too. use_mailmap: bool = false, /// Whether color is on. color: bool = false, /// The escape sequence commit IDs are colored with (`color.diff.commit`). commit_color: []const u8 = "\x1b[33m", /// `--expand-tabs`; null for the default, eight in the formats that /// indent the message and none in the others. expand_tabs: ?usize = null, /// How trailers are read. trailers: trailers.Settings = .{}, /// The terminal's width, for padding to a column from its edge. columns: usize = 80, /// `--parents`: the parents after the commit's own ID in the built-in /// formats' first line. show_parents: bool = false, /// `--children`: the children after it. show_children: bool = false, /// Whether the mark is written before it: `--left-right` for the /// sides, `--boundary` for a boundary commit. show_mark: bool = false, /// Whether the built-in formats write their first line, the commit's /// own ID and what goes with it; `git rev-list` writes its own. first_line: bool = true, /// For `git rev-list`, whose built-in formats differ: `email` begins /// with `commit` and has no `[PATCH]`. rev_list: bool = false, /// `--decorate`: the built-in formats' first line ends with the /// commit's decorations. decorate: bool = false, /// `--source`: the built-in formats' first line has what the commit /// was reached from after a tab. show_source: bool = false, /// The colors of decorations. decoration_colors: DecorationColors = .{}, }; /// What `write` can fail with. `InvalidColor` is a color spec git refuses, /// `TimestampBeforeEpoch` a date git refuses to show, and /// `UnsupportedEncoding` what this library does not do; the diagnostics say which, in git's words where git has /// them. `MalformedCommit` is a commit that cannot be read at all. pub const Error = error{ InvalidColor, TimestampBeforeEpoch, DateOutOfRange, UnsupportedConversion, UnsupportedEncoding, MalformedCommit, } || ShortenError || Allocator.Error; /// Writes `shown` as `format` describes it: what git writes for one /// commit, without the line feeds that separate one commit from the next /// (see `Format.terminated`). pub fn write(gpa: Allocator, w: *Writer, format: *const Format, shown: Shown, settings: Settings, diag: ?*Diagnostics) Error!void { var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); const original = Commit.parse(shown.id.object_format, shown.contents) catch return error.MalformedCommit; const reads_text = switch (format.*) { .builtin => true, .user => |u| u.reads_text, }; // A built-in format is written from the commit in the output // encoding. A format of the user's is written from the commit in // UTF-8, and what it writes is then converted, as a whole, into the // output encoding, or left as it is where that fails: a character in // the format itself is converted along with the commit's. // `reference` is a format string of git's own, and goes the same way. const is_user = switch (format.*) { .user => true, .builtin => |b| b == .reference, }; const commit_encoding = if (is_user) "UTF-8" else settings.output_encoding; const reencoded: ?[]u8 = if (reads_text) try reencode(gpa, shown.contents, original.encoding(), commit_encoding, diag) else null; defer if (reencoded) |x| gpa.free(x); // Converted from EBCDIC, a commit can be left with no blank line // anywhere in it, and git's formats of the user's then read past its // end, writing whatever bytes they find there. if (is_user) if (reencoded) |x| if (std.mem.find(u8, x, "\n\n") == null) if (original.encoding()) |e| if (encoding.ebcdic(e)) { if (diag) |d| d.setDetail("{f}", .{encoding.Refusal{ .name = e, .side = .commit, .reason = .ebcdic }}); return error.UnsupportedEncoding; }; var as_written = shown; if (reencoded) |x| as_written.contents = x; const commit = if (reencoded) |x| original.reencoded(x) else original; var r: Renderer = .{ .gpa = gpa, .out = &out, .shown = as_written, .commit = commit, .declared_encoding = original.encoding(), .settings = settings, .diag = diag }; switch (format.*) { .builtin => |b| try r.builtin(b), .user => |u| try r.user(u.items), } const output = settings.output_encoding; if (is_user and output.len != 0 and !encoding.same("UTF-8", output)) { var refusal: encoding.Refusal = .{}; const converted = encoding.convert(gpa, out.items, "UTF-8", output, &refusal) catch |err| switch (err) { error.UnsupportedEncoding => { if (diag) |d| d.setDetail("{f}", .{refusal}); return error.UnsupportedEncoding; }, error.OutOfMemory => return error.OutOfMemory, }; if (converted) |c| { defer gpa.free(c); w.writeAll(c) catch return error.WriteFailed; return; } } w.writeAll(out.items) catch return error.WriteFailed; } /// The commit `contents`, declaring `declared`, as it is written in /// `output`: newly allocated, or null if it is written as it is. /// `UnsupportedEncoding` names, through `diag`, the encoding not /// converted. pub fn reencode(gpa: Allocator, contents: []const u8, declared: ?[]const u8, output: []const u8, diag: ?*Diagnostics) Error!?[]u8 { var refusal: encoding.Refusal = .{}; return encoding.reencodeCommit(gpa, contents, declared, output, &refusal) catch |err| switch (err) { error.UnsupportedEncoding => { if (diag) |d| d.setDetail("{f}", .{refusal}); return error.UnsupportedEncoding; }, error.OutOfMemory => return error.OutOfMemory, }; } /// Writes an annotated tag as `git show` writes it before the object it /// tags: `tag `, the tagger as `format` writes a commit's author (in /// `medium` with the date, in `fuller` as `TaggerDate:`, in `email` as /// `From:` and `Date:`, in `oneline` not at all, and otherwise without /// the date), a blank line and the message as it is. pub fn writeTag(gpa: Allocator, w: *Writer, format: *const Format, contents: []const u8, object_format: object.Format, settings: Settings, diag: ?*Diagnostics) (Error || error{MalformedTag})!void { const Tag = @import("tag.zig").Tag; const t = Tag.parse(object_format, contents, null) catch return error.MalformedTag; var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); try out.print(gpa, "tag {s}\n", .{t.name}); const b: ?Builtin = switch (format.*) { .builtin => |x| x, .user => null, }; if (t.tagger()) |id| if (b != Builtin.oneline) { const m: Mailmap.Mapped = if (settings.use_mailmap and settings.mailmap != null) settings.mailmap.?.map(id.name, id.email) else .{ .name = id.name, .email = id.email }; const d = id.date() orelse date.Date{ .seconds = 0, .zone = 0 }; var dw: std.Io.Writer.Allocating = .init(gpa); defer dw.deinit(); const Line = struct { label: []const u8, date_label: ?[]const u8, mode: ?date.Mode }; const line: Line = if (b) |x| switch (x) { .medium => .{ .label = "Tagger: ", .date_label = "Date: ", .mode = settings.date_mode }, .fuller => .{ .label = "Tagger: ", .date_label = "TaggerDate: ", .mode = settings.date_mode }, .email, .mboxrd => .{ .label = "From: ", .date_label = "Date: ", .mode = .{ .style = .rfc } }, else => .{ .label = "Tagger: ", .date_label = null, .mode = null }, } else .{ .label = "Tagger: ", .date_label = null, .mode = null }; try out.print(gpa, "{s}{s} <{s}>\n", .{ line.label, m.name, m.email }); if (line.date_label) |dl| { date.format(&dw.writer, d, line.mode.?, .{ .now = settings.now, .zone = settings.zone }, diag) catch |err| switch (err) { error.WriteFailed => return error.OutOfMemory, error.TimestampBeforeEpoch => { if (diag) |dg| dg.setDetail("Timestamp before Unix epoch: {d} {d}", .{ d.seconds, d.zone }); return error.TimestampBeforeEpoch; }, else => |e| return e, }; try out.print(gpa, "{s}{s}\n", .{ dl, dw.written() }); } }; try out.append(gpa, '\n'); try out.appendSlice(gpa, t.message); w.writeAll(out.items) catch return error.WriteFailed; } /// Writes what git writes of a commit in `format` before anything that can /// fail: the built-in formats' first line, which git writes as soon as it /// begins a commit and leaves written when the rest of it fails. pub fn writeFirstLine(gpa: Allocator, w: *Writer, format: *const Format, shown: Shown, settings: Settings) Error!void { const b = switch (format.*) { .builtin => |x| x, .user => return, }; if (!settings.first_line) return; const commit = Commit.parse(shown.id.object_format, shown.contents) catch return error.MalformedCommit; var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); var r: Renderer = .{ .gpa = gpa, .out = &out, .shown = shown, .commit = commit, .settings = settings, .diag = null }; switch (b) { .oneline => { try r.firstLine(null); try r.append(" "); }, .reference => {}, .email, .mboxrd => { try r.append("From "); try r.shorten(r.out, shown.id, null); try r.append(" Mon Sep 17 00:00:00 2001\n"); }, else => try r.firstLine("commit "), } w.writeAll(out.items) catch return error.WriteFailed; } const Who = enum { author, committer }; /// One commit's message, taken apart as the placeholders want it. const Message = struct { whole: []const u8, /// The subject paragraph, as it is. subject_lines: []const u8, /// Everything after the subject paragraph and the blank lines after it. body: []const u8, }; fn isBlankLine(line: []const u8) bool { for (line) |c| if (!isBlank(c)) return false; return true; } fn isBlank(c: u8) bool { return c == ' ' or c == '\t' or c == '\n' or c == '\r'; } fn trimEndBlank(text: []const u8) []const u8 { return std.mem.trimEnd(u8, text, " \t\n\r"); } /// The line starting at `at`, without its line feed, and where the next /// begins. fn lineAt(text: []const u8, at: usize) struct { line: []const u8, next: usize } { const nl = std.mem.findScalarPos(u8, text, at, '\n'); return if (nl) |n| .{ .line = text[at..n], .next = n + 1 } else .{ .line = text[at..], .next = text.len }; } fn splitMessage(message: []const u8) Message { var at: usize = 0; while (at < message.len) { const l = lineAt(message, at); if (!isBlankLine(l.line)) break; at = l.next; } const subject_start = at; while (at < message.len) { const l = lineAt(message, at); if (isBlankLine(l.line)) break; at = l.next; } const subject_end = at; while (at < message.len) { const l = lineAt(message, at); if (!isBlankLine(l.line)) break; at = l.next; } return .{ .whole = message, .subject_lines = message[subject_start..subject_end], .body = message[at..] }; } /// Writes the subject: its lines, ends trimmed, joined with spaces. fn writeSubject(list: *std.ArrayList(u8), gpa: Allocator, subject_lines: []const u8) Allocator.Error!void { var at: usize = 0; var first = true; while (at < subject_lines.len) { const l = lineAt(subject_lines, at); at = l.next; if (!first) try list.append(gpa, ' '); first = false; try list.appendSlice(gpa, trimEndBlank(l.line)); } } /// The columns `text` takes, or null if it holds a control character or /// bytes that are not UTF-8. fn measured(text: []const u8) ?usize { var n: usize = 0; var i: usize = 0; while (i < text.len) { const len = std.unicode.utf8ByteSequenceLength(text[i]) catch return null; if (i + len > text.len) return null; _ = std.unicode.utf8Decode(text[i..][0..len]) catch return null; const c = width.next(text[i..]); n += c.width orelse return null; i += len; } return n; } fn writeSanitized(list: *std.ArrayList(u8), gpa: Allocator, subject: []const u8) Allocator.Error!void { const start = list.items.len; var dash = false; for (subject) |c| { if (std.ascii.isAlphanumeric(c) or c == '.' or c == '_') { if (dash and list.items.len > start) try list.append(gpa, '-'); dash = false; if (c == '.' and list.items.len > start and list.items[list.items.len - 1] == '.') continue; try list.append(gpa, c); } else dash = true; } while (list.items.len > start and list.items[list.items.len - 1] == '.') list.items.len -= 1; } const Renderer = struct { gpa: Allocator, out: *std.ArrayList(u8), shown: Shown, /// The commit as it is written, in the output encoding. commit: Commit, /// The commit's own `encoding` header, for `%e`. declared_encoding: ?[]const u8 = null, settings: Settings, diag: ?*Diagnostics, /// `%C(auto)` is in effect. auto_color: bool = false, padding: ?Padding = null, /// A color has been written since the padding. after_color: bool = false, /// How many spaces `%>>` takes back once the text it pads is written, /// and how many it may: those written just before it. steal: usize = 0, spaces_before: usize = 0, /// The colors written since the padding, padded with what follows. pending_colors: std.ArrayList(u8) = .empty, /// The wrapping in effect, and where the text it applies to began. wrap: Wrap = .{}, wrap_start: usize = 0, /// The trailer block, read when first asked for. block: ?trailers.Block = null, fn list(r: *Renderer) *std.ArrayList(u8) { return r.out; } fn bytes(r: *Renderer) []const u8 { return r.out.items; } fn append(r: *Renderer, text: []const u8) Error!void { try r.out.appendSlice(r.gpa, text); } fn appendByte(r: *Renderer, b: u8) Error!void { try r.out.append(r.gpa, b); } fn message(r: *Renderer) Message { return splitMessage(r.commit.message); } fn identity(r: *Renderer, who: Who) ?Identity { const line = switch (who) { .author => r.commit.author(), .committer => r.commit.committer(), }; return line; } /// Writes `id`, shortened to at least `length` digits unless null. fn shorten(r: *Renderer, out: *std.ArrayList(u8), id: Id, length: ?usize) Error!void { var aw: std.Io.Writer.Allocating = .fromArrayList(r.gpa, out); defer out.* = aw.toArrayList(); const n = length orelse return aw.writer.print("{f}", .{id}) catch error.OutOfMemory; const a = r.settings.abbreviator; a.shorten(a.context, &aw.writer, id, n) catch |err| switch (err) { error.WriteFailed => return error.OutOfMemory, else => |e| return e, }; } // -- format strings ------------------------------------------------- fn user(r: *Renderer, items: []const Item) Error!void { defer if (r.block) |*b| b.deinit(r.gpa); defer r.pending_colors.deinit(r.gpa); for (items) |item| try r.one(item); // Padding and a color at the very end: git takes the end as a // placeholder it does not know. if (r.after_color) try r.padNothing(); try r.finishWrap(); } /// Writes the padding still pending around nothing at all, as a /// placeholder that writes nothing, or one git does not know, takes /// it. fn padNothing(r: *Renderer) Error!void { const p = r.padding orelse return; r.padding = null; r.after_color = false; var padded: std.ArrayList(u8) = .empty; defer padded.deinit(r.gpa); try r.pad(p, r.pending_colors.items, &padded); r.pending_colors.clearRetainingCapacity(); try r.append(padded.items); } fn one(r: *Renderer, item: Item) Error!void { // Padding followed by a color: git writes the color and takes what // follows as the placeholder to pad, its `%` already read, so that // text or a placeholder it cannot answer is padded as nothing and // written after, without that `%`. if (r.padding != null and r.after_color) switch (item.kind) { .color => |c| return r.writeColor(c), .literal => { try r.padNothing(); return r.append(item.text); }, .percent, .unknown => { try r.padNothing(); return r.append(item.text[1..]); }, .padding => return r.padNothing(), .wrap => |wr| return r.startWrap(wr), else => if (item.magic != .none) { try r.padNothing(); return r.append(item.text[1..]); } else { var tmp: std.ArrayList(u8) = .empty; defer tmp.deinit(r.gpa); if (!try r.expand(item.kind, &tmp)) { try r.padNothing(); return r.append(item.text[1..]); } r.after_color = false; try tmp.insertSlice(r.gpa, 0, r.pending_colors.items); r.pending_colors.clearRetainingCapacity(); return r.place(item, tmp.items, true); }, }; switch (item.kind) { .literal => return r.append(item.text), .padding => |p| { // A padding placeholder takes padding still pending, as // nothing, and is itself used up by it. if (r.padding != null) return r.padNothing(); r.padding = p; r.after_color = false; const b = r.bytes(); var n: usize = 0; while (n < b.len and b[b.len - 1 - n] == ' ') n += 1; r.spaces_before = n; return; }, .wrap => |wr| return r.startWrap(wr), .color => |c| return r.writeColor(c), .percent => { if (item.magic == .none) return r.append("%"); return r.place(item, "%", false); }, .unknown => return r.unanswered(item), else => {}, } var tmp: std.ArrayList(u8) = .empty; defer tmp.deinit(r.gpa); const handled = try r.expand(item.kind, &tmp); if (!handled) return r.unanswered(item); return r.place(item, tmp.items, true); } /// Writes a placeholder git does not know or cannot answer as it is — /// after magic, without the `%` and the magic character — after any /// padding pending, which it takes as nothing, magic and all. fn unanswered(r: *Renderer, item: Item) Error!void { try r.place(item, "", true); if (item.magic == .none) return r.append(item.text); return r.append(item.text[2..]); } /// Begins a wrapped region; padding still pending is written inside /// it, as nothing. fn startWrap(r: *Renderer, wr: Wrap) Error!void { if (r.padding != null) { // git writes the placeholder it pads into a buffer of its own, // so the wrapping it begins there begins at that buffer's start, // which is the start of the commit's output. try r.padNothing(); r.wrap = wr; r.wrap_start = 0; return; } try r.rewrap(wr); } fn writeColor(r: *Renderer, c: Color) Error!void { var tmp: std.ArrayList(u8) = .empty; defer tmp.deinit(r.gpa); try r.colorItem(c, &tmp); if (r.padding != null) { // Padded along with what follows, as git writes both into the // buffer it pads. try r.pending_colors.appendSlice(r.gpa, tmp.items); r.after_color = true; return; } try r.append(tmp.items); } /// Writes an expansion with its magic, padded if padding is pending /// and the placeholder takes it. fn place(r: *Renderer, item: Item, text: []const u8, takes_padding: bool) Error!void { var padded: std.ArrayList(u8) = .empty; defer padded.deinit(r.gpa); var result = text; if (takes_padding) { if (r.padding) |p| { r.padding = null; try r.pad(p, text, &padded); result = padded.items; } } switch (item.magic) { .none => {}, .plus => if (result.len != 0) try r.appendByte('\n'), .space => if (result.len != 0) try r.appendByte(' '), .minus => if (result.len == 0) { const l = r.list(); while (l.items.len > 0 and l.items[l.items.len - 1] == '\n') l.items.len -= 1; }, } if (r.steal != 0) { var excess = r.steal; r.steal = 0; const l = r.list(); while (excess > 0 and l.items.len > 0 and l.items[l.items.len - 1] == ' ') { l.items.len -= 1; excess -= 1; } } try r.append(result); } /// The column output has reached on its last line. fn currentColumn(r: *Renderer) usize { // git looks for the line's start in a C string, which a NUL ends. const all = r.bytes(); const b = all[0 .. std.mem.findScalar(u8, all, 0) orelse all.len]; const start = if (std.mem.findScalarLast(u8, b, '\n')) |n| n + 1 else 0; return width.ofString(b[start..]); } fn pad(r: *Renderer, p: Padding, text: []const u8, out: *std.ArrayList(u8)) Error!void { const target: i64 = if (p.column) col: { const c: i64 = if (p.columns < 0) @as(i64, @intCast(r.settings.columns)) + p.columns else p.columns; break :col c - @as(i64, @intCast(r.currentColumn())); } else p.columns; const have = width.ofString(text); if (target <= 0) { try out.appendSlice(r.gpa, text); return; } const want: usize = @intCast(target); if (have > want) { if (p.truncate != .none) return truncate(r.gpa, out, text, want, p.truncate); // `%>>`: the spaces to take from what is already written, // once any magic has been written. if (p.steal) r.steal = @min(have - want, r.spaces_before); try out.appendSlice(r.gpa, text); return; } const spaces = want - have; const left: usize = switch (p.align_to) { .left => 0, .right => spaces, .center => spaces / 2, }; try out.appendNTimes(r.gpa, ' ', left); try out.appendSlice(r.gpa, text); try out.appendNTimes(r.gpa, ' ', spaces - left); } /// Wraps what has been written since the wrapping in effect began, as /// that wrapping says, and begins `next`; git does nothing when they /// are the same. git wraps the text as a C string, so a NUL in it /// ends it, even with no wrapping in effect. fn rewrap(r: *Renderer, next: Wrap) Error!void { const wr = r.wrap; if (wr.width == next.width and wr.indent1 == next.indent1 and wr.indent2 == next.indent2) return; const l = r.list(); if (r.wrap_start < l.items.len) { const whole = l.items[r.wrap_start..]; const region = try r.gpa.dupe(u8, whole[0 .. std.mem.findScalar(u8, whole, 0) orelse whole.len]); defer r.gpa.free(region); l.items.len = r.wrap_start; var wrapped: std.ArrayList(u8) = .empty; defer wrapped.deinit(r.gpa); try wrapText(r.gpa, &wrapped, region, @intCast(@max(0, wr.width)), @intCast(@max(0, wr.indent1)), @intCast(@max(0, wr.indent2)), false); try r.append(wrapped.items); } r.wrap_start = l.items.len; r.wrap = next; } /// The wrapping at the end of a commit's output. fn finishWrap(r: *Renderer) Error!void { try r.rewrap(.{}); } fn colorItem(r: *Renderer, c: Color, out: *std.ArrayList(u8)) Error!void { switch (c) { .auto => { r.auto_color = true; if (r.settings.color and r.bytes().len != 0) try out.appendSlice(r.gpa, "\x1b[m"); }, .spec => |s| { r.auto_color = false; if (!r.settings.color and !s.always) return; var buffer: [color_max]u8 = undefined; const escape = parseColor(s.text, &buffer) orelse { if (r.diag) |d| d.setDetail("invalid color value: {s}", .{s.text}); return error.InvalidColor; }; try out.appendSlice(r.gpa, escape); }, } } /// Expands a placeholder into `out`; false if git writes it as it is. fn expand(r: *Renderer, kind: Kind, out: *std.ArrayList(u8)) Error!bool { const gpa = r.gpa; switch (kind) { .newline => try out.append(gpa, '\n'), .byte => |b| try out.append(gpa, b), .field => |f| { const ab = r.settings.abbreviator.length; switch (f) { .commit, .commit_abbrev => { const colored = r.auto_color and r.settings.color; if (colored) try out.appendSlice(gpa, r.settings.commit_color); try r.shorten(out, r.shown.id, if (f == .commit) null else ab); if (colored) try out.appendSlice(gpa, "\x1b[m"); }, .tree, .tree_abbrev => try r.shorten(out, r.commit.tree, if (f == .tree) null else ab), .parents, .parents_abbrev => for (r.shown.parents, 0..) |p, i| { if (i != 0) try out.append(gpa, ' '); try r.shorten(out, p, if (f == .parents) null else ab); }, .subject => try writeSubject(out, gpa, r.message().subject_lines), // Only the subject's first line, unlike `%s`. .sanitized_subject => try writeSanitized(out, gpa, lineAt(r.message().subject_lines, 0).line), .body => try out.appendSlice(gpa, r.message().body), .raw_body => try out.appendSlice(gpa, r.commit.message), .encoding => if (r.declared_encoding) |e| try out.appendSlice(gpa, e), .mark => try out.append(gpa, switch (r.shown.mark) { .boundary => '-', .left => '<', else => '>', }), } }, .person => |p| return r.expandPerson(p, out), .decorate => |f| try r.writeDecorations(out, f, r.auto_color and r.settings.color), // Without a source, as in `git rev-list`, written as it is. .source => if (r.shown.source) |src| try out.appendSlice(gpa, src) else return false, .describe => |n| if (n < r.shown.descriptions.len) { if (r.shown.descriptions[n]) |t| try out.appendSlice(gpa, t); }, .trailers => |o| { if (r.block == null) r.block = try trailers.parse(gpa, r.commit.message, r.settings.trailers); var aw: std.Io.Writer.Allocating = .fromArrayList(gpa, out); defer out.* = aw.toArrayList(); trailers.write(&aw.writer, r.block.?, o, r.settings.trailers) catch return error.OutOfMemory; }, else => unreachable, } return true; } fn expandPerson(r: *Renderer, p: Person, out: *std.ArrayList(u8)) Error!bool { const gpa = r.gpa; const id = r.identity(if (p.committer) .committer else .author) orelse { // An identity line git cannot read: some placeholders show // nothing, and the rest are written as they are. return std.mem.findScalar(u8, "nedtDri", p.what) != null; }; const mapped: Mailmap.Mapped = if (r.settings.mailmap) |m| m.map(id.name, id.email) else .{ .name = id.name, .email = id.email }; switch (p.what) { 'n' => try out.appendSlice(gpa, id.name), 'N' => try out.appendSlice(gpa, mapped.name), 'e' => try out.appendSlice(gpa, id.email), 'E' => try out.appendSlice(gpa, mapped.email), 'l' => try out.appendSlice(gpa, localPart(id.email)), 'L' => try out.appendSlice(gpa, localPart(mapped.email)), 't' => try out.appendSlice(gpa, id.timestamp), else => { const d = id.date() orelse return std.mem.findScalar(u8, "dDri", p.what) != null; const mode: date.Mode = switch (p.what) { 'd' => r.settings.date_mode, 'D' => .{ .style = .rfc }, 'r' => .{ .style = .relative }, 'i' => .{ .style = .iso }, 'I' => .{ .style = .iso_strict }, 's' => .{ .style = .short }, 'h' => .{ .style = .human }, else => unreachable, }; try r.writeDate(out, d, mode); }, } return true; } fn writeDate(r: *Renderer, out: *std.ArrayList(u8), d: date.Date, mode: date.Mode) Error!void { var aw: std.Io.Writer.Allocating = .fromArrayList(r.gpa, out); defer out.* = aw.toArrayList(); date.format(&aw.writer, d, mode, .{ .now = r.settings.now, .zone = r.settings.zone }, r.diag) catch |err| switch (err) { error.WriteFailed => return error.OutOfMemory, error.TimestampBeforeEpoch => { if (r.diag) |diag| diag.setDetail("Timestamp before Unix epoch: {d} {d}", .{ d.seconds, d.zone }); return error.TimestampBeforeEpoch; }, else => |e| return e, }; } // -- built-in formats ------------------------------------------------- fn builtin(r: *Renderer, b: Builtin) Error!void { const gpa = r.gpa; switch (b) { .oneline => { if (r.settings.first_line) { try r.firstLine(null); try r.append(" "); } var s: std.ArrayList(u8) = .empty; defer s.deinit(gpa); try writeSubject(&s, gpa, r.message().subject_lines); try r.append(s.items); try r.append("\n"); }, .reference => { var f = parseUser(gpa, "%C(auto)%h (%s, %ad)", false, null) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => unreachable, }; defer f.arena.deinit(); const saved = r.settings; defer r.settings = saved; if (!r.settings.date_given) r.settings.date_mode = .{ .style = .short }; try r.user(f.items); }, .email, .mboxrd => try r.email(b == .mboxrd), .raw => { try r.firstLine("commit "); // The headers as they are written in the output encoding, // the `encoding` header already made to say so. var at: usize = 0; const h = r.commit.headers; while (at < h.len) { const l = lineAt(h, at); try r.append(l.line); try r.append("\n"); at = l.next; } try r.messageLines(false, 0); }, .short, .medium, .full, .fuller => { try r.firstLine("commit "); const body_start = r.bytes().len; defer if (r.bytes().len == body_start) r.appendByte('\n') catch {}; try r.mergeLine(); const tabs = r.settings.expand_tabs orelse (if (b.indented()) @as(usize, 8) else 0); switch (b) { .short => try r.identityLine("Author: ", .author), .medium => { try r.identityLine("Author: ", .author); try r.dateLine("Date: ", .author); }, .full => { try r.identityLine("Author: ", .author); try r.identityLine("Commit: ", .committer); }, .fuller => { try r.identityLine("Author: ", .author); try r.dateLine("AuthorDate: ", .author); try r.identityLine("Commit: ", .committer); try r.dateLine("CommitDate: ", .committer); }, else => unreachable, } try r.messageLines(b == .short, tabs); }, } } /// Writes the commit's decorations as `f` says, colored as git colors /// them: names in their own colors, the rest in the commit's. Nothing /// for a commit with none. fn writeDecorations(r: *Renderer, out: *std.ArrayList(u8), f: DecorateFormat, colored: bool) Error!void { const decorations = r.shown.decorations; if (decorations.len == 0) return; const s = r.settings; const Piece = struct { fn put(gpa: Allocator, o: *std.ArrayList(u8), color: []const u8, text: []const u8, on: bool, always: bool) Allocator.Error!void { if (text.len == 0 and !always) return; if (on) try o.appendSlice(gpa, color); try o.appendSlice(gpa, text); if (on) try o.appendSlice(gpa, "\x1b[m"); } }; const gpa = r.gpa; const cc = s.commit_color; try Piece.put(gpa, out, cc, f.prefix, colored, false); for (decorations, 0..) |d, i| { if (i != 0) try Piece.put(gpa, out, cc, f.separator, colored, false); const kc = s.decoration_colors.of(d.kind); if (d.kind == .tag) try Piece.put(gpa, out, kc, f.tag, colored, false); try Piece.put(gpa, out, kc, d.name, colored, true); if (d.target) |t| { try Piece.put(gpa, out, cc, f.pointer, colored, true); try Piece.put(gpa, out, s.decoration_colors.branch, t, colored, true); } } try Piece.put(gpa, out, cc, f.suffix, colored, false); } /// The first line's commit ID, with what `--parents`, `--children` and /// the marks put around it, colored as git colors it; `prefix` is /// `commit ` for the formats that have it. fn firstLine(r: *Renderer, prefix: ?[]const u8) Error!void { const s = r.settings; if (!s.first_line) return; if (s.color) try r.append(s.commit_color); if (prefix) |p| try r.append(p); if (s.show_mark and r.shown.mark != .none) { try r.appendByte(switch (r.shown.mark) { .left => '<', .right => '>', .boundary => '-', .none => unreachable, }); try r.appendByte(' '); } const length: ?usize = if (s.abbrev_commit) s.abbreviator.length else null; try r.shorten(r.out, r.shown.id, length); if (s.show_parents) for (r.shown.parents) |p| { try r.appendByte(' '); try r.shorten(r.out, p, length); }; if (s.show_children) for (r.shown.children) |c| { try r.appendByte(' '); try r.shorten(r.out, c, length); }; if (r.shown.from) |f| { try r.append(" (from "); try r.shorten(r.out, f, length); try r.appendByte(')'); } if (s.color) try r.append("\x1b[m"); if (s.show_source) if (r.shown.source) |src| { try r.appendByte('\t'); try r.append(src); }; if (s.decorate) try r.writeDecorations(r.out, .{}, s.color); if (prefix != null) try r.append("\n"); } fn mergeLine(r: *Renderer) Error!void { if (r.shown.parents.len < 2) return; try r.append("Merge:"); const length: ?usize = if (r.settings.full_merge_ids) null else r.settings.abbreviator.length; for (r.shown.parents) |p| { try r.appendByte(' '); try r.shorten(r.out, p, length); } try r.append("\n"); } fn shownIdentity(r: *Renderer, who: Who) ?Mailmap.Mapped { const id = r.identity(who) orelse return null; if (r.settings.use_mailmap) if (r.settings.mailmap) |m| return m.map(id.name, id.email); return .{ .name = id.name, .email = id.email }; } fn identityLine(r: *Renderer, label: []const u8, who: Who) Error!void { const m = r.shownIdentity(who) orelse return; try r.append(label); try r.append(m.name); try r.append(" <"); try r.append(m.email); try r.append(">\n"); } fn dateLine(r: *Renderer, label: []const u8, who: Who) Error!void { const id = r.identity(who) orelse return; try r.append(label); const d = id.date() orelse date.Date{ .seconds = 0, .zone = 0 }; var tmp: std.ArrayList(u8) = .empty; defer tmp.deinit(r.gpa); try r.writeDate(&tmp, d, r.settings.date_mode); try r.append(tmp.items); try r.append("\n"); } /// The message, indented four spaces, from its first line that is not /// blank to its last, each line's blank space at its end removed and /// tabs expanded to multiples of `tabs`; only the subject paragraph if /// `subject_only`. fn messageLines(r: *Renderer, subject_only: bool, tabs: usize) Error!void { const m = r.commit.message; var at: usize = 0; while (at < m.len) { const l = lineAt(m, at); if (!isBlankLine(l.line)) break; at = l.next; } var end = m.len; if (subject_only) { var e = at; while (e < m.len) { const l = lineAt(m, e); if (isBlankLine(l.line)) break; e = l.next; } end = e; } // Back from the end over blank lines. var last_end = at; { var p = at; while (p < end) { const l = lineAt(m[0..end], p); if (!isBlankLine(l.line)) last_end = l.next; p = l.next; } } if (last_end <= at) return; try r.append("\n"); var p = at; while (p < last_end) { const l = lineAt(m[0..last_end], p); p = l.next; try r.append(" "); const line = trimEndBlank(l.line); if (tabs == 0) { try r.append(line); } else { // Each stretch before a tab is measured. One holding a // control character or bytes that are not UTF-8, which // have no width, leaves it and the rest of the line as // they are, as git was seen to leave them. var column: usize = 0; var i: usize = 0; while (std.mem.findScalarPos(u8, line, i, '\t')) |tab| { const cols = measured(line[i..tab]) orelse break; try r.append(line[i..tab]); column += cols; const n = tabs - column % tabs; for (0..n) |_| try r.appendByte(' '); column += n; i = tab + 1; } try r.append(line[i..]); } try r.append("\n"); } } fn email(r: *Renderer, mboxrd: bool) Error!void { const gpa = r.gpa; const s = r.settings; if (!s.first_line) { // `git rev-list` writes `commit ` itself. } else { try r.append("From "); try r.shorten(r.out, r.shown.id, null); try r.append(" Mon Sep 17 00:00:00 2001\n"); } const msg = r.message(); var subject: std.ArrayList(u8) = .empty; defer subject.deinit(gpa); try writeSubject(&subject, gpa, msg.subject_lines); if (r.shownIdentity(.author)) |m| { try r.append("From: "); // `git rev-list` encodes nothing. const encoded = needsEncodedWords(m.name) and !s.rev_list; if (encoded) { try encodeWord(gpa, r.out, m.name, "From: ".len, .phrase, s.output_encoding); } else { var name: std.ArrayList(u8) = .empty; defer name.deinit(gpa); if (needsQuotes(m.name)) { try name.append(gpa, '"'); for (m.name) |c| { if (c == '"' or c == '\\') try name.append(gpa, '\\'); try name.append(gpa, c); } try name.append(gpa, '"'); } else try name.appendSlice(gpa, m.name); // Folded as a subject is, at its blanks. var folded: std.ArrayList(u8) = .empty; defer folded.deinit(gpa); try foldHeader(gpa, &folded, name.items, "From: ".len); try r.append(folded.items); } // An address that would take the line past 78 columns, or // past 76 after an encoded word, begins the next; the columns // are bytes. const items = r.list().items; const column = items.len - if (std.mem.findScalarLast(u8, items, '\n')) |nl| nl + 1 else 0; const limit: usize = if (encoded) 76 else 78; try r.append(if (column + " <".len + m.email.len + ">".len > limit) "\n <" else " <"); try r.append(m.email); try r.append(">\n"); { const d = r.identity(.author).?.date() orelse date.Date{ .seconds = 0, .zone = 0 }; try r.append("Date: "); var tmp: std.ArrayList(u8) = .empty; defer tmp.deinit(gpa); try r.writeDate(&tmp, d, .{ .style = .rfc }); try r.append(tmp.items); try r.append("\n"); } } const prefix = if (s.rev_list) "Subject: " else "Subject: [PATCH] "; try r.append(prefix); if (needsEncodedWords(subject.items) and !s.rev_list) { try encodeWord(gpa, r.out, subject.items, prefix.len, .text, s.output_encoding); } else if (subject.items.len != 0) { var folded: std.ArrayList(u8) = .empty; defer folded.deinit(gpa); try foldHeader(gpa, &folded, subject.items, prefix.len); try r.append(folded.items); } else { // No space after `[PATCH]` when there is no subject. r.list().items.len -= 1; } try r.append("\n"); if (needsEncodedWords(msg.body) or needsEncodedWords(subject.items)) { try r.append("MIME-Version: 1.0\nContent-Type: text/plain; charset="); try r.append(s.output_encoding); try r.append("\nContent-Transfer-Encoding: 8bit\n"); } try r.append("\n"); // The body, from its first line that is not blank to its last, the // blank space at each line's end removed. const body = msg.body; var last_end: usize = 0; { var p: usize = 0; while (p < body.len) { const l = lineAt(body, p); if (!isBlankLine(l.line)) last_end = l.next; p = l.next; } } var p: usize = 0; while (p < last_end) { const l = lineAt(body[0..last_end], p); p = l.next; const line = trimEndBlank(l.line); if (mboxrd) { const after = std.mem.trimStart(u8, line, ">"); if (std.mem.startsWith(u8, after, "From ")) try r.appendByte('>'); } try r.append(line); try r.append("\n"); } } }; fn localPart(email_address: []const u8) []const u8 { return email_address[0 .. std.mem.findScalar(u8, email_address, '@') orelse email_address.len]; } /// Whether git takes a header's text for one to write as encoded words, /// and a subject's or body's for one that needs MIME headers: any byte /// past ASCII, or an ESC, as the escape sequences of ISO-2022-JP have. /// git was seen to treat no other control character so. fn needsEncodedWords(text: []const u8) bool { for (text) |c| if (c >= 0x80 or c == 0x1b) return true; return false; } /// Writes `text` as RFC 2047 encoded words, `=??q?...?=`, as git /// writes a header past ASCII: every byte past ASCII, blank space and /// control character as `=XX`, and those of the others a `phrase` may not /// hold (anything but letters, digits and `!*+-/`) or `text` may not (`=`, /// `?` and `_`) the same; a word is closed and another begun on a line of /// its own before a character would take the line past 76 columns. /// /// The charset is the output encoding, by the name it was given, whether /// or not the text could be converted into it. In UTF-8, by a name git /// takes for it, a character is never split; in anything else, git /// splits wherever a byte would take the line past 76 columns. fn encodeWord(gpa: Allocator, out: *std.ArrayList(u8), text: []const u8, column: usize, kind: enum { phrase, text }, charset_name: []const u8) Allocator.Error!void { var start_buffer: std.ArrayList(u8) = .empty; defer start_buffer.deinit(gpa); try start_buffer.print(gpa, "=?{s}?q?", .{charset_name}); const start = start_buffer.items; const utf8 = encoding.isUtf8(charset_name); try out.appendSlice(gpa, start); var line = column + start.len; var i: usize = 0; while (i < text.len) { const len: usize = if (text[i] < 0x80 or !utf8) 1 else len: { const l = std.unicode.utf8ByteSequenceLength(text[i]) catch break :len 1; if (i + l > text.len) break :len 1; _ = std.unicode.utf8Decode(text[i..][0..l]) catch break :len 1; break :len l; }; const unit = text[i..][0..len]; const c = unit[0]; const plain = len == 1 and c > ' ' and c < 0x7f and switch (kind) { .phrase => std.ascii.isAlphanumeric(c) or std.mem.findScalar(u8, "!*+-/", c) != null, .text => c != '=' and c != '?' and c != '_', }; const width_needed: usize = if (plain) 1 else 3 * len; if (line + width_needed + 2 > 76) { try out.appendSlice(gpa, "?=\n "); try out.appendSlice(gpa, start); line = 1 + start.len; } if (plain) { try out.append(gpa, c); } else for (unit) |b| try out.print(gpa, "={X:0>2}", .{b}); line += width_needed; i += len; } try out.appendSlice(gpa, "?="); } /// Whether a name must be quoted in an email header: RFC 5322's specials. fn needsQuotes(name: []const u8) bool { for (name) |c| if (std.mem.findScalar(u8, "()<>@,;:\\\".[]", c) != null) return true; return false; } /// Folds a header's value at 78 columns, starting at column `column`, the /// way git folds a subject: as `%w(78,0,1)` would, with a break allowed /// before the first word. fn foldHeader(gpa: Allocator, out: *std.ArrayList(u8), text: []const u8, column: usize) Allocator.Error!void { try wrapFrom(gpa, out, text, 78, column, 0, 1, true); } /// Wraps `text` as `%w(line_width,indent1,indent2)` does; `break_first` /// allows a break before the first word, as a folded header has. pub fn wrapText(gpa: Allocator, out: *std.ArrayList(u8), text: []const u8, line_width: usize, indent1: usize, indent2: usize, break_first: bool) Allocator.Error!void { return wrapFrom(gpa, out, text, line_width, indent1, indent1, indent2, break_first); } /// The columns a character takes when wrapping, at `column`. In text /// that is not UTF-8 (`bytes`), git was seen to count a column a byte, /// control characters included, beside tabs and color escapes. fn wrapWidth(text: []const u8, column: i64, bytes: bool) struct { len: usize, width: i64 } { const c = text[0]; if (width.escapeLength(text)) |l| return .{ .len = l, .width = 0 }; if (c == '\t') return .{ .len = 1, .width = 8 - @mod(column, 8) }; if (c == '\r' or bytes) return .{ .len = 1, .width = 1 }; const ch = width.next(text); return .{ .len = ch.len, .width = if (ch.width) |w| w else -1 }; } fn wrapFrom(gpa: Allocator, out: *std.ArrayList(u8), text: []const u8, line_width: usize, start_column: usize, indent1: usize, indent2: usize, break_first: bool) Allocator.Error!void { if (text.len == 0) return; if (line_width == 0) { try out.appendNTimes(gpa, ' ', indent1); for (text, 0..) |c, k| { try out.append(gpa, c); if (c == '\n' and k + 1 < text.len) try out.appendNTimes(gpa, ' ', indent2); } return; } const base = out.items.len; const not_utf8 = !std.unicode.utf8ValidateSlice(text); const w: i64 = @intCast(line_width); var column: i64 = @intCast(start_column); var pending: usize = indent1; // Where a line may be broken: the blank to replace, or the place to // insert a line feed. const Break = struct { at: usize, replace: bool }; var brk: ?Break = if (break_first) .{ .at = base, .replace = false } else null; const S = struct { fn put(g: Allocator, o: *std.ArrayList(u8), p: *usize, b: []const u8) Allocator.Error!void { if (p.* != 0) { try o.appendNTimes(g, ' ', p.*); p.* = 0; } try o.appendSlice(g, b); } fn columnsOf(t: []const u8, from: i64, bytes: bool) i64 { var col = from; var k: usize = 0; while (k < t.len) { const ch = wrapWidth(t[k..], col, bytes); col += ch.width; k += ch.len; } return col; } fn finish(g: Allocator, o: *std.ArrayList(u8), col: *i64, b: *?Break, p: *usize, lw: i64, indent: usize, bytes: bool) Allocator.Error!void { if (col.* <= lw) return; const k = b.* orelse return; const tail_start = if (k.replace) k.at + 1 else k.at; const tail = try g.dupe(u8, o.items[tail_start..]); defer g.free(tail); o.items.len = k.at; try o.append(g, '\n'); if (tail.len != 0) { try o.appendNTimes(g, ' ', indent); try o.appendSlice(g, tail); } else p.* = indent; col.* = columnsOf(tail, @intCast(indent), bytes); b.* = null; } }; var i: usize = 0; while (i < text.len) { const c = text[i]; if (c == '\n') { try S.finish(gpa, out, &column, &brk, &pending, w, indent2, not_utf8); if (i + 1 == text.len) { try S.put(gpa, out, &pending, "\n"); i += 1; break; } const nx = text[i + 1]; if (nx == '\n') { try S.put(gpa, out, &pending, "\n\n"); pending = indent2; column = @intCast(indent2); brk = null; i += 2; continue; } if (!std.ascii.isAlphanumeric(nx)) { try S.put(gpa, out, &pending, "\n"); pending = indent2; column = @intCast(indent2); brk = null; i += if (nx == ' ' or nx == '\t' or nx == '\r') 2 else 1; continue; } try S.put(gpa, out, &pending, " "); column += 1; brk = .{ .at = out.items.len, .replace = false }; i += 1; continue; } if (c == ' ' or c == '\t' or c == '\r') { try S.finish(gpa, out, &column, &brk, &pending, w, indent2, not_utf8); try S.put(gpa, out, &pending, ""); brk = .{ .at = out.items.len, .replace = true }; column += wrapWidth(text[i..], column, not_utf8).width; try out.append(gpa, c); i += 1; continue; } const ch = wrapWidth(text[i..], column, not_utf8); column += ch.width; try S.put(gpa, out, &pending, text[i..][0..ch.len]); i += ch.len; } try S.finish(gpa, out, &column, &brk, &pending, w, indent2, not_utf8); } /// Truncates `text` to `want` columns with `..` at its end, start or /// middle, as git does: the characters beginning in the columns cut are /// removed and `..` written where they began (a wide character begun /// before them is kept whole, and the result is a column wider than /// asked), while those taking none — /// control characters, line feeds among them — are kept, after the `..`. fn truncate(gpa: Allocator, out: *std.ArrayList(u8), text: []const u8, want: usize, mode: anytype) Allocator.Error!void { // git truncates nothing it does not read as UTF-8. if (!width.isGitUtf8(text)) return out.appendSlice(gpa, text); const total = width.of(text); const keep = if (want >= 2) want - 2 else 0; // The columns cut: [from, to). const from: usize, const to: usize = switch (mode) { .end => .{ keep, total }, .start => .{ 0, total - keep }, .middle => .{ keep / 2, total - (keep - keep / 2) }, .none => unreachable, }; var column: usize = 0; var i: usize = 0; var dots = false; while (i < text.len) { var len: usize = 0; var w: usize = 0; if (width.escapeLength(text[i..])) |l| { len = l; } else { const c = width.next(text[i..]); len = c.len; w = c.width orelse 0; } const cut = w != 0 and column >= from and column < to; if (cut and !dots) { try out.appendSlice(gpa, ".."); dots = true; } if (!cut) try out.appendSlice(gpa, text[i..][0..len]); column += w; i += len; } if (!dots) try out.appendSlice(gpa, ".."); } // -- colors ------------------------------------------------------------------- /// The longest escape sequence a color spec can make. pub const color_max = 96; /// The escape sequence for a color spec, as git-config(1)'s "color" type /// reads one, written into `buffer`; empty for a spec of no color, and /// null for one git refuses. pub fn parseColor(spec: []const u8, buffer: *[color_max]u8) ?[]const u8 { const Color3 = union(enum) { none, basic: u8, bright: u8, default, index: u8, rgb: [3]u8 }; var fg: Color3 = .none; var bg: Color3 = .none; var colors: usize = 0; var reset = false; var attributes: u32 = 0; var it = std.mem.tokenizeAny(u8, spec, " \t"); const names = [_][]const u8{ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white" }; const attribute_names = [_]struct { []const u8, u5 }{ .{ "bold", 1 }, .{ "dim", 2 }, .{ "italic", 3 }, .{ "ul", 4 }, .{ "blink", 5 }, .{ "reverse", 7 }, .{ "strike", 9 }, }; while (it.next()) |word| { var c: ?Color3 = null; if (std.ascii.eqlIgnoreCase(word, "normal")) { c = .none; } else if (std.ascii.eqlIgnoreCase(word, "default")) { c = .default; } else if (std.mem.eql(u8, word, "reset")) { reset = true; continue; } else if (word[0] == '#') { const hex = word[1..]; if (hex.len == 6) { var rgb: [3]u8 = undefined; for (0..3) |k| rgb[k] = std.fmt.parseInt(u8, hex[k * 2 ..][0..2], 16) catch return null; c = .{ .rgb = rgb }; } else if (hex.len == 3) { var rgb: [3]u8 = undefined; for (0..3) |k| { const v = std.fmt.parseInt(u8, hex[k..][0..1], 16) catch return null; rgb[k] = v * 17; } c = .{ .rgb = rgb }; } else return null; } else if (std.fmt.parseInt(i32, word, 10)) |n| { if (n < -1 or n > 255) return null; c = if (n == -1) .none else if (n < 8) .{ .basic = @intCast(n) } else if (n < 16) .{ .bright = @intCast(n - 8) } else .{ .index = @intCast(n) }; } else |_| { const bright = word.len > 6 and std.ascii.eqlIgnoreCase(word[0..6], "bright"); const base = if (bright) word[6..] else word; for (names, 0..) |n, k| if (std.ascii.eqlIgnoreCase(n, base)) { c = if (bright) .{ .bright = @intCast(k) } else .{ .basic = @intCast(k) }; }; } if (c) |color| { switch (colors) { 0 => fg = color, 1 => bg = color, else => return null, } colors += 1; continue; } var negate = false; var name = word; if (std.mem.startsWith(u8, name, "no-")) { negate = true; name = name[3..]; } else if (std.mem.startsWith(u8, name, "no")) { negate = true; name = name[2..]; } for (attribute_names) |a| { if (std.mem.eql(u8, a[0], name)) { // Turning off bold and dim are both 22. const code: u5 = if (negate) (if (a[1] == 1) 22 else a[1] + 20) else a[1]; attributes |= @as(u32, 1) << code; break; } } else return null; } var w: Writer = .fixed(buffer); if (!reset and attributes == 0 and fg == .none and bg == .none) return ""; w.writeAll("\x1b[") catch return null; var first = true; if (reset) first = false; var code: u6 = 0; while (code < 32) : (code += 1) { if (attributes & (@as(u32, 1) << @intCast(code)) == 0) continue; if (!first) w.writeByte(';') catch return null; first = false; w.print("{d}", .{code}) catch return null; } for ([_]struct { Color3, u8 }{ .{ fg, 30 }, .{ bg, 40 } }) |pair| { const color, const offset = pair; if (color == .none) continue; if (!first) w.writeByte(';') catch return null; first = false; switch (color) { .none => unreachable, .basic => |k| w.print("{d}", .{offset + k}) catch return null, .bright => |k| w.print("{d}", .{offset + 60 + k}) catch return null, .default => w.print("{d}", .{offset + 9}) catch return null, .index => |k| w.print("{d};5;{d}", .{ offset + 8, k }) catch return null, .rgb => |rgb| w.print("{d};2;{d};{d};{d}", .{ offset + 8, rgb[0], rgb[1], rgb[2] }) catch return null, } } w.writeByte('m') catch return null; return w.buffered(); } const testing = std.testing; const fuzz_input = @import("testing/fuzz.zig"); test "color specs are read as git reads them" { const cases = [_]struct { []const u8, ?[]const u8 }{ .{ "red", "\x1b[31m" }, .{ "red bold", "\x1b[1;31m" }, .{ "#ff0000", "\x1b[38;2;255;0;0m" }, .{ "ul blink reverse italic strike dim nobold", "\x1b[2;3;4;5;7;9;22m" }, .{ "123", "\x1b[38;5;123m" }, .{ "red blue", "\x1b[31;44m" }, .{ "brightred", "\x1b[91m" }, .{ "", "" }, .{ " ", "" }, .{ "normal", "" }, .{ "normal red", "\x1b[41m" }, .{ "default", "\x1b[39m" }, .{ "reset", "\x1b[m" }, .{ "reset green", "\x1b[;32m" }, .{ "no-bold", "\x1b[22m" }, .{ "bold ul bold", "\x1b[1;4m" }, .{ "red green blue", null }, .{ "#F1b", "\x1b[38;2;255;17;187m" }, .{ "256", null }, .{ "-1", "" }, .{ "brightdefault", null }, .{ "BOLD", null }, .{ "Red", "\x1b[31m" }, .{ "1", "\x1b[31m" }, .{ "15", "\x1b[97m" }, .{ "0 1", "\x1b[30;41m" }, .{ "noitalic noul noblink noreverse nostrike", "\x1b[23;24;25;27;29m" }, .{ "bold nobold", "\x1b[1;22m" }, .{ "bright red", null }, .{ "default default", "\x1b[39;49m" }, .{ "#12", null }, .{ "#gggggg", null }, .{ "bold reset", "\x1b[;1m" }, }; for (cases) |case| { var buffer: [color_max]u8 = undefined; const got = parseColor(case[0], &buffer); errdefer std.debug.print("color \"{s}\"\n", .{case[0]}); if (case[1]) |want| { try testing.expect(got != null); try testing.expectEqualStrings(want, got.?); } else try testing.expect(got == null); } } test "fuzz: format strings" { try fuzz_input.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [256]u8 = undefined; const text = fuzz_input.bytes(smith, &buffer); var f = parse(testing.allocator, text, null) catch |err| switch (err) { error.UnsupportedPlaceholder, error.UnknownFormat => return, else => return err, }; defer f.deinit(); switch (f) { .builtin => {}, .user => |u| { var total: usize = 0; for (u.items) |item| { total += item.text.len; // Expanding `%xNN`, `%n` and `%%` only ever shortens // a separator, NULs included. if (item.kind == .trailers) for ([_]?[]const u8{ item.kind.trailers.separator, item.kind.trailers.key_value_separator }) |v| if (v) |sep| try testing.expect(sep.len <= item.text.len); } // The pieces cover the text, less a `format:` prefix. try testing.expect(total <= text.len); }, } } }.one, .{ .corpus = &.{ @embedFile("fuzz/pretty-formats"), "%h %s%n%b", "%<(20,trunc)%s|%>>(5)%h", "%w(72,4,8)%B", "%C(auto)%h%Creset %C(red bold)%s", "%(trailers:key=Signed-off-by,valueonly,separator=%x2C )", "format:%x00%+s%-b% e", "%(trailers:separator=[%x00x],key_value_separator=Q%x00R)" } }); } test "fuzz: decoration and describe options" { try fuzz_input.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [256]u8 = undefined; const text = fuzz_input.bytes(smith, &buffer); var f = parseUser(testing.allocator, text, true, null) catch |err| switch (err) { error.UnsupportedPlaceholder => return, else => return err, }; defer f.arena.deinit(); for (f.items) |item| switch (item.kind) { .decorate => |d| for ([_][]const u8{ d.prefix, d.suffix, d.separator, d.pointer, d.tag }) |v| try testing.expect(std.mem.findScalar(u8, v, 0) == null), .describe => |n| try testing.expect(n < f.describes.len), else => {}, }; } }.one, .{ .corpus = &.{ @embedFile("fuzz/decorate-options"), @embedFile("fuzz/decorate-crash"), "%(decorate:prefix=,suffix=,tag=,separator= )", "%(describe:match=x,match=y)" } }); } test "a NUL ends a decoration option's value" { // git stops a value at `%x00`; a NUL byte in the format itself, which // git could never be handed, ends it the same way. var f = try parseUser(testing.allocator, "%(decorate:prefix=[\x00x,suffix=]%x41,tag=T%x00Z)", true, null); defer f.arena.deinit(); try testing.expectEqual(1, f.items.len); const d = f.items[0].kind.decorate; try testing.expectEqualStrings("[", d.prefix); try testing.expectEqualStrings("]A", d.suffix); try testing.expectEqualStrings("T", d.tag); } test "a NUL in a trailers separator is kept" { // git writes `%x00` in `separator=` and `key_value_separator=` as a NUL // byte between the trailers; a NUL byte in the format itself is kept too. var f = try parseUser(testing.allocator, "%(trailers:separator=[%x00x]\x00y,key_value_separator=Q%x00R)", true, null); defer f.arena.deinit(); try testing.expectEqual(1, f.items.len); const o = f.items[0].kind.trailers; try testing.expectEqualStrings("[\x00x]\x00y", o.separator.?); try testing.expectEqualStrings("Q\x00R", o.key_value_separator.?); } test "wrapping matches git's %w" { const cases = [_]struct { []const u8, usize, usize, usize, []const u8 }{ .{ "a b c d e f", 3, 0, 0, "a b\nc d\ne f" }, .{ "aa bb", 5, 0, 0, "aa \nbb" }, .{ "a \nb", 5, 0, 0, "a b" }, .{ "a\n b", 5, 0, 0, "a\nb" }, .{ "a\n\n\nb", 5, 0, 0, "a\n\n b" }, .{ "a\tb c", 10, 0, 0, "a\tb\nc" }, .{ "\tb", 5, 0, 0, "\nb" }, .{ "a bb ccc", 4, 1, 2, " a\n bb\n ccc" }, .{ "trail \nnext", 10, 0, 0, "trail \nnext" }, .{ "abc def\nghi", 6, 0, 0, "abc\ndef \nghi" }, .{ "", 10, 1, 0, "" }, .{ " \t", 1, 0, 3, " \n" }, .{ "\raaaaa\nccc\n", 5, 0, 1, "\n aaaaa \n ccc\n" }, }; for (cases) |case| { var out: std.ArrayList(u8) = .empty; defer out.deinit(testing.allocator); try wrapText(testing.allocator, &out, case[0], case[1], case[2], case[3], false); errdefer std.debug.print("wrapping \"{f}\"\n", .{std.zig.fmtString(case[0])}); try testing.expectEqualStrings(case[4], out.items); } }