Something went wrong. Try again.
A Zig library that reads git repositories without running git, with log, show and diff output byte-identical to git's.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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(<spec>)`, `%Cred`, `%Cgreen`, `%Cblue` and `%Creset`//! write escape sequences when color is on, `%C(always,<spec>)` 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=<n>`. abbrev: ?usize = null, /// `match=<pattern>`, each. match: []const []const u8 = &.{}, /// `exclude=<pattern>`, 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<x>` or `%c<x>`.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.<name>` aliases, for `resolve`.pub const Aliases = struct { context: *const anyopaque, get: *const fn (context: *const anyopaque, name: []const u8) ?[]const u8,};
/// What `--pretty=<text>` means once `pretty.<name>` 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=<text>` (or `--format=<text>`), 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:<options>)`.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:<options>)`, 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 <parent>)` after the commit. from: ?Id = null,};
/// The colors decorations are written in, each an escape sequence, as/// `color.decorate.<slot>` 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 <name>`, 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 <id>` 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, `=?<charset>?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); }}