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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The syntax of a git configuration file, as git-config(1) describes it//! and as `git config --list` was seen to read it, on bytes with no I/O.//!//! What the documentation leaves out, found by experiment://!//! - A UTF-8 byte order mark is skipped at the very start of a file, and//! nowhere else.//! - A carriage return before a line feed is part of the line end//! everywhere. Alone, it is blank space at the start of a line and in an//! unquoted value, and an ordinary byte elsewhere. Vertical tab and form//! feed are never blank space.//! - A section name may hold letters, digits, `-` and `.`, and may start//! with any of them; it is lowered, dots and all, so `[Core.Sub]` is the//! section `core.sub`. Between it and a quoted subsection go spaces or//! tabs, at least one, and the closing quote must be followed at once by//! `]`. A subsection may hold any byte but a line feed, NUL included;//! `\` keeps the byte after it, whatever it is. What follows `]` on the//! same line is read as though it began a line.//! - A variable may appear before any section, and is then named by its//! name alone.//! - An unquoted value loses blank space at its start and end, but keeps//! it inside; blank space is dropped while nothing has yet been kept,//! so `k = "" x` is `x`. `\` followed by a line end joins the next line,//! in quotes or out, and a `\` at the very end of the file is dropped.//! - Keys and values end at a NUL byte, as git's strings do: the rest of//! the value is read, and lost.//! - The line a malformed file is reported at is usually the line the//! mistake is on, but a section header that runs into the end of the//! file, or that ends a line after its closing quote, is reported at//! the line after it, as git reports it.
const std = @import("std");const Allocator = std.mem.Allocator;
/// One variable read from a file.pub const Entry = struct { /// The variable's full name: its section and variable names lowered, /// its subsection as written, joined by dots. Valid until the next /// call to `Parser.next`. key: []const u8, /// Its value, or null for a variable written without `=`, which is /// true. Borrowed from the input when nothing in it needed undoing, /// and otherwise valid until the next call to `Parser.next`. value: ?[]const u8, /// The line it ends on, counting from 1. line: u32,};
/// What can go wrong reading a configuration file.pub const Error = error{ /// The file is malformed: git's "bad config line". `Parser.line` /// says which line git names. BadConfigLine,} || Allocator.Error;
/// Reads the variables of a configuration file one at a time.pub const Parser = struct { input: []const u8, pos: usize, /// The line being read; after an error, the line git reports. line: u32 = 1, /// The current section, and subsection if any, as keys begin. prefix: std.ArrayList(u8) = .empty, key: std.ArrayList(u8) = .empty, value: std.ArrayList(u8) = .empty, pending: std.ArrayList(u8) = .empty, /// Whether the value just read ended with a line feed. value_ended_line: bool = false,
const bom = "\xef\xbb\xbf";
/// A parser for the file `input`. pub fn init(input: []const u8) Parser { return .{ .input = input, .pos = if (std.mem.startsWith(u8, input, bom)) bom.len else 0 }; }
/// Frees what the parser has allocated. pub fn deinit(p: *Parser, gpa: Allocator) void { p.prefix.deinit(gpa); p.key.deinit(gpa); p.value.deinit(gpa); p.pending.deinit(gpa); }
const eof: u16 = 256;
/// The next byte, with a carriage return and line feed read as one /// line feed, or `eof`. fn nextChar(p: *Parser) u16 { if (p.pos >= p.input.len) return eof; const c = p.input[p.pos]; p.pos += 1; if (c == '\r' and p.pos < p.input.len and p.input[p.pos] == '\n') { p.pos += 1; return '\n'; } return c; }
fn fail(p: *Parser, line: u32) Error { p.line = line; return error.BadConfigLine; }
/// The next variable, or null at the end of the file. pub fn next(p: *Parser, gpa: Allocator) Error!?Entry { while (true) { const c = p.nextChar(); switch (c) { eof => return null, '\n' => p.line += 1, ' ', '\t', '\r' => {}, '#', ';' => p.skipComment(), '[' => try p.sectionHeader(gpa), else => { if (c < 256 and std.ascii.isAlphabetic(@intCast(c))) return try p.variable(gpa, @intCast(c)); return p.fail(p.line); }, } } }
/// Skips to the end of the line, leaving the line feed to be read. fn skipComment(p: *Parser) void { const end = std.mem.findScalarPos(u8, p.input, p.pos, '\n') orelse p.input.len; p.pos = end; }
fn sectionHeader(p: *Parser, gpa: Allocator) Error!void { p.prefix.clearRetainingCapacity(); while (true) { const c = p.nextChar(); switch (c) { ']' => { if (p.prefix.items.len == 0) return p.fail(p.line); return; }, ' ', '\t' => { if (p.prefix.items.len == 0) return p.fail(p.line); return p.subsection(gpa); }, eof => return p.fail(p.line + 1), else => { if (c < 256) { const b: u8 = @intCast(c); if (std.ascii.isAlphanumeric(b) or b == '-' or b == '.') { try p.prefix.append(gpa, std.ascii.toLower(b)); continue; } } return p.fail(p.line); }, } } }
/// The quoted subsection of a header, from the blank space after the /// section name to the closing `]`. fn subsection(p: *Parser, gpa: Allocator) Error!void { var c = p.nextChar(); while (c == ' ' or c == '\t') c = p.nextChar(); if (c != '"') return p.fail(p.line); try p.prefix.append(gpa, '.'); while (true) { c = p.nextChar(); switch (c) { '"' => break, '\n', eof => return p.fail(p.line), '\\' => { const e = p.nextChar(); if (e == '\n' or e == eof) return p.fail(p.line); try p.prefix.append(gpa, @intCast(e)); }, else => try p.prefix.append(gpa, @intCast(c)), } } c = p.nextChar(); switch (c) { ']' => {}, '\n', eof => return p.fail(p.line + 1), else => return p.fail(p.line), } }
fn variable(p: *Parser, gpa: Allocator, first: u8) Error!Entry { p.key.clearRetainingCapacity(); try p.key.appendSlice(gpa, p.prefix.items); if (p.prefix.items.len != 0) try p.key.append(gpa, '.'); try p.key.append(gpa, std.ascii.toLower(first)); var c = p.nextChar(); while (c < 256 and (std.ascii.isAlphanumeric(@intCast(c)) or c == '-')) : (c = p.nextChar()) { try p.key.append(gpa, std.ascii.toLower(@intCast(c))); } while (c == ' ' or c == '\t') c = p.nextChar(); const key = truncateAtNul(p.key.items); switch (c) { eof => return .{ .key = key, .value = null, .line = p.line }, '\n' => { const line = p.line; p.line += 1; return .{ .key = key, .value = null, .line = line }; }, '=' => {}, else => return p.fail(p.line), } const value = try p.readValue(gpa); const line = p.line; if (p.value_ended_line) p.line += 1; return .{ .key = key, .value = truncateAtNul(value), .line = line }; }
fn isBlank(c: u16) bool { return c == ' ' or c == '\t' or c == '\r'; }
/// The value after `=`, up to and including the line feed that ends /// it. fn readValue(p: *Parser, gpa: Allocator) Error![]const u8 { if (p.simpleValue()) |v| return v; p.value.clearRetainingCapacity(); p.value_ended_line = false; // Blank space not yet known to be inside the value. p.pending.clearRetainingCapacity(); var quoted = false; while (true) { const c = p.nextChar(); switch (c) { eof => { if (quoted) return p.fail(p.line); return p.value.items; }, '\n' => { if (quoted) return p.fail(p.line); p.value_ended_line = true; return p.value.items; }, '\\' => { const e = p.nextChar(); const byte: u8 = switch (e) { // A backslash at the very end joins a line that // is not there. '\n', eof => { p.line += 1; continue; }, 't' => '\t', 'n' => '\n', 'b' => 0x08, '"' => '"', '\\' => '\\', else => return p.fail(p.line), }; try p.keepPending(gpa); try p.value.append(gpa, byte); }, '"' => quoted = !quoted, else => { if (!quoted and (c == ';' or c == '#')) { p.skipComment(); p.value_ended_line = p.nextChar() == '\n'; return p.value.items; } if (!quoted and isBlank(c)) { if (p.value.items.len == 0) continue; try p.pending.append(gpa, @intCast(c)); continue; } try p.keepPending(gpa); try p.value.append(gpa, @intCast(c)); }, } } }
/// Keeps the blank space read since the last byte kept, now that /// something follows it. fn keepPending(p: *Parser, gpa: Allocator) Allocator.Error!void { try p.value.appendSlice(gpa, p.pending.items); p.pending.clearRetainingCapacity(); }
/// The value, borrowed from the input, if it has no quotes, escapes or /// NULs to undo; having consumed it and the line feed after it. fn simpleValue(p: *Parser) ?[]const u8 { const rest = p.input[p.pos..]; const line_end = std.mem.findScalar(u8, rest, '\n') orelse rest.len; const line = rest[0..line_end]; var end = line.len; for (line, 0..) |c, i| switch (c) { '"', '\\', 0 => return null, ';', '#' => { end = i; break; }, else => {}, }; const value = std.mem.trim(u8, line[0..end], " \t\r"); p.value_ended_line = line_end != rest.len; p.pos += if (p.value_ended_line) line_end + 1 else line_end; return value; }};
fn truncateAtNul(s: []const u8) []const u8 { return s[0 .. std.mem.findScalar(u8, s, 0) orelse s.len];}
/// A variable's full name split into its parts. `section` and `name` are/// as the key holds them; `subsection` is null if there is none.pub const KeyParts = struct { section: []const u8, subsection: ?[]const u8, name: []const u8,};
/// Splits a full variable name at its first and last dots.pub fn splitKey(key: []const u8) KeyParts { const last = std.mem.findScalarLast(u8, key, '.') orelse return .{ .section = "", .subsection = null, .name = key }; const first = std.mem.findScalar(u8, key, '.').?; return .{ .section = key[0..first], .subsection = if (first == last) null else key[first + 1 .. last], .name = key[last + 1 ..], };}
/// Why a variable name given to look one up, or in `-c`, was refused,/// in git's words: "key does not contain a section", "key does not/// contain variable name", "invalid key", or "invalid key (newline)".pub const KeyError = error{ KeyHasNoSection, KeyHasNoName, InvalidKey, InvalidKeyNewline,};
/// Writes `key`, a variable name as a person would write it —/// `Section.Subsection.Name` — as configuration files spell it: the/// section and the name lowered and the subsection left alone. Refuses/// what git refuses: a key without a section or a name, a section of/// anything but letters, digits and `-`, a name that does not start with/// a letter or holds anything but letters, digits and `-`, and a/// subsection holding a line feed.pub fn canonicalKey(key: []const u8, out: []u8) KeyError![]u8 { std.debug.assert(out.len >= key.len); const last = std.mem.findScalarLast(u8, key, '.') orelse return error.KeyHasNoSection; if (last == 0) return error.KeyHasNoSection; if (last == key.len - 1) return error.KeyHasNoName; const first = std.mem.findScalar(u8, key, '.').?; for (key[0..first], 0..) |c, i| { if (!(std.ascii.isAlphanumeric(c) or c == '-')) return error.InvalidKey; out[i] = std.ascii.toLower(c); } for (key[first..last], first..) |c, i| { if (c == '\n') return error.InvalidKeyNewline; out[i] = c; } out[last] = '.'; const name = key[last + 1 ..]; if (!std.ascii.isAlphabetic(name[0])) return error.InvalidKey; for (name, last + 1..) |c, i| { if (!(std.ascii.isAlphanumeric(c) or c == '-')) return error.InvalidKey; out[i] = std.ascii.toLower(c); } return out[0..key.len];}
const testing = std.testing;const fuzz_input = @import("../testing/fuzz.zig");
/// What a file reads as, in `git config --list -z`'s format, or the error/// and line.fn listOf(input: []const u8) ![]u8 { const gpa = testing.allocator; var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); var p: Parser = .init(input); defer p.deinit(gpa); while (p.next(gpa) catch |err| switch (err) { error.BadConfigLine => { try out.writer.print("bad config line {d}", .{p.line}); return out.toOwnedSlice(); }, else => |e| return e, }) |e| { try out.writer.writeAll(e.key); if (e.value) |v| try out.writer.print("\n{s}", .{v}); try out.writer.writeByte(0); } return out.toOwnedSlice();}
fn expectList(want: []const u8, input: []const u8) !void { const got = try listOf(input); defer testing.allocator.free(got); errdefer std.debug.print("input: \"{f}\"\n", .{std.zig.fmtString(input)}); try testing.expectEqualStrings(want, got);}
test "sections and subsections are named as git names them" { try expectList("core.k\nv\x00", "[Core]\nK=v"); try expectList("core.sub.k\nv\x00", "[core \t \"sub\"]\nk=v"); try expectList("core.sub.k\nv\x00", "[Core.Sub]\nk=v"); try expectList("core.a\"b\\ctd.k\nv\x00", "[core \"a\\\"b\\\\c\\td\"]\nk=v"); try expectList("core..k\nv\x00", "[core.]\nk=v"); try expectList("core.a.b.k\nv\x00", "[core.A \"b\"]\nk=v"); try expectList("1core.k\nv\x00", "[1core]\nk=v"); try expectList("core.junk\x00", "[core]junk"); try expectList("k\nv\x00", "k=v"); try expectList("core.a\nv\x00", "[core \"a\x00b\"]\nk=v"); try expectList("bad config line 1", "[ core ]\nk=v"); try expectList("bad config line 1", "[core\"sub\"]\nk=v"); try expectList("bad config line 1", "[core \"sub\" ]\nk=v"); try expectList("bad config line 1", "[co_re]\nk=v"); try expectList("bad config line 1", "[core\n]"); try expectList("bad config line 2", "[core"); try expectList("bad config line 2", "[core \"a\"\n"); try expectList("bad config line 2", "[core \"a\""); try expectList("bad config line 1", "[core \"a\n"); try expectList("bad config line 1", "[core "); try expectList("bad config line 4", "\n\n[core \"a\"\n");}
test "values are read as git reads them" { try expectList("s.k\na b\x00", "[s]\nk = a b \n"); try expectList("s.k\n a \x00", "[s]\nk = \" a \" \n"); try expectList("s.k\na b c\x00", "[s]\nk = a\" b \"c # x\n"); try expectList("s.k\na\x00", "[s]\nk = a;b\n"); try expectList("s.k\n\n\t\x08\"\\\x00", "[s]\nk = \\n\\t\\b\\\"\\\\\n"); try expectList("s.k\na b\x00", "[s]\nk = a\\\n b\n"); try expectList("s.k\na b\x00", "[s]\nk = a \\\n b \n"); try expectList("s.k\nb\x00", "[s]\nk=\\\n b\n"); try expectList("s.k\nb\x00", "[s]\nk=\"\" b\n"); try expectList("s.k\na\x00s.j\nb\x00", "[s]\r\nk = a\r\nj=b\r\n"); try expectList("s.k\na\rb\x00", "[s]\nk = a\rb\n"); try expectList("s.k\na\r\x0c\x00", "[s]\nk=a\r\x0c\n"); try expectList("s.k\na\x00s.j\n1\x00", "[s]\nk=a\x00b\nj=1\n"); try expectList("s.k\nv\x00", "\xef\xbb\xbf[s]\nk=v\n"); try expectList("s.k\x00", "[s]\nk \n"); try expectList("s.k\x00s.j\n\x00", "[s]\nk\r\nj=\n"); try expectList("s.k\na\x00", "[s]\nk=a\\"); try expectList("s.k\nv\x00", "[s]\nk=v\\\n# not a comment\n"); try expectList("bad config line 1", "\xef\xbb[s]\nk=v\n"); try expectList("bad config line 2", "\n\xef\xbb\xbf[s]\n"); try expectList("bad config line 2", "[s]\nk = \\a\n"); try expectList("bad config line 2", "[s]\nk ;c\n"); try expectList("bad config line 2", "[s]\nk_=v\n"); try expectList("bad config line 2", "[s]\nk=\"a\n"); try expectList("bad config line 2", "[s]\nk\r \n"); try expectList("bad config line 2", "[s]\nk=a\\\r"); try expectList("bad config line 3", "[s]\nk=a\\\n\\q\n"); try expectList("bad config line 2", "[s]\n\x0bk=v\n");}
test "a key is spelled as files spell it, or refused as git refuses it" { var buffer: [64]u8 = undefined; try testing.expectEqualStrings("s.Sub.k", try canonicalKey("S.Sub.K", &buffer)); try testing.expectEqualStrings("s..k", try canonicalKey("s..k", &buffer)); try testing.expectEqualStrings("s.a.b.c", try canonicalKey("s.a.b.c", &buffer)); try testing.expectError(error.KeyHasNoSection, canonicalKey("k", &buffer)); try testing.expectError(error.KeyHasNoSection, canonicalKey(".k", &buffer)); try testing.expectError(error.KeyHasNoName, canonicalKey("s.", &buffer)); try testing.expectError(error.InvalidKey, canonicalKey("s.1k", &buffer)); try testing.expectError(error.InvalidKey, canonicalKey("s_x.k", &buffer)); try testing.expectError(error.InvalidKeyNewline, canonicalKey("s.a\nb.k", &buffer)); const parts = splitKey("a.b.c.d"); try testing.expectEqualStrings("a", parts.section); try testing.expectEqualStrings("b.c", parts.subsection.?); try testing.expectEqualStrings("d", parts.name);}
fn fuzzOne(_: void, smith: *testing.Smith) anyerror!void { const gpa = testing.allocator; var buffer: [4096]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); var p: Parser = .init(input); defer p.deinit(gpa); var last_line: u32 = 1; while (p.next(gpa) catch |err| switch (err) { error.BadConfigLine => { // A line no further than one past the end. try testing.expect(p.line >= last_line); return; }, else => |e| return e, }) |e| { try testing.expect(e.line >= last_line); last_line = e.line; try testing.expect(std.mem.findScalar(u8, e.key, 0) == null); if (e.value) |v| try testing.expect(std.mem.findScalar(u8, v, 0) == null); }}
test "fuzz: configuration files" { try fuzz_input.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("../fuzz/config"), @embedFile("../fuzz/config-crlf"), @embedFile("../fuzz/config-bad"), } });}