// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Configuration values read as the types git-config(1) describes: //! booleans, integers with their unit suffixes, and paths with `~` and //! `%(prefix)` expanded. What the documentation leaves out was found by //! asking `git config --type=...` about each spelling. //! //! - An integer is read as C's `strtoimax` reads one in base 0: blank //! space first is skipped (vertical tab and form feed included), a sign //! may follow, then `0x` for hexadecimal, `0b` for binary or a leading //! `0` for octal. A single `k`, `m` or `g` in either case may follow; //! anything else after the digits, or no digits at all, is an invalid //! unit. Integers are 64-bit, but a boolean spelled as a number, and //! the integer half of `bool-or-int`, must fit in 32. //! - A boolean is `true`, `yes`, `on`, `false`, `no` or `off` in any case, //! the empty string (false), no value at all (true), or a number, true //! unless it is zero. const std = @import("std"); const Allocator = std.mem.Allocator; /// Why a value is not the integer it was asked to be, in git's words. pub const IntError = error{ /// Not a number, or a number followed by something other than a unit. InvalidUnit, /// A number too large or too small for the type, before or after its /// unit is applied. OutOfRange, }; /// Reads `text` as an integer of type `T` (`i64` for `--type=int`, `i32` /// where git uses a C `int`), with its unit applied. pub fn parseInt(comptime T: type, text: []const u8) IntError!T { var i: usize = 0; while (i < text.len and isCSpace(text[i])) i += 1; var negative = false; if (i < text.len and (text[i] == '+' or text[i] == '-')) { negative = text[i] == '-'; i += 1; } var base: u8 = 10; if (i + 1 < text.len and text[i] == '0' and (text[i + 1] == 'x' or text[i + 1] == 'X') and i + 2 < text.len and std.ascii.isHex(text[i + 2])) { base = 16; i += 2; } else if (i + 1 < text.len and text[i] == '0' and (text[i + 1] == 'b' or text[i + 1] == 'B') and i + 2 < text.len and (text[i + 2] == '0' or text[i + 2] == '1')) { base = 2; i += 2; } else if (i < text.len and text[i] == '0') { base = 8; } const digits_start = i; // Accumulated as a magnitude, saturating, so that overflow is known // whatever the number of digits. var magnitude: u128 = 0; var overflow = false; while (i < text.len) : (i += 1) { const d = std.fmt.charToDigit(text[i], base) catch break; magnitude = magnitude * base + d; if (magnitude > std.math.maxInt(u64)) { overflow = true; magnitude = std.math.maxInt(u64); } } if (i == digits_start) return error.InvalidUnit; // strtoimax's own range comes first. const limit: u128 = if (negative) @as(u128, std.math.maxInt(i64)) + 1 else std.math.maxInt(i64); if (overflow or magnitude > limit) return error.OutOfRange; const factor: u128 = switch (text.len - i) { 0 => 1, 1 => switch (text[i]) { 'k', 'K' => 1 << 10, 'm', 'M' => 1 << 20, 'g', 'G' => 1 << 30, else => return error.InvalidUnit, }, else => return error.InvalidUnit, }; const scaled = magnitude * factor; const type_limit: u128 = if (negative) @as(u128, std.math.maxInt(T)) + 1 else std.math.maxInt(T); if (scaled > type_limit) return error.OutOfRange; const signed: i128 = if (negative) -@as(i128, @intCast(scaled)) else @intCast(scaled); return @intCast(signed); } fn isCSpace(c: u8) bool { return c == ' ' or (c >= '\t' and c <= '\r'); } /// Whether `value` is one of the words for true or false, or no value at /// all, which is true; null if it is neither. pub fn boolWord(value: ?[]const u8) ?bool { const v = value orelse return true; if (v.len == 0) return false; for ([_][]const u8{ "true", "yes", "on" }) |w| if (std.ascii.eqlIgnoreCase(v, w)) return true; for ([_][]const u8{ "false", "no", "off" }) |w| if (std.ascii.eqlIgnoreCase(v, w)) return false; return null; } /// Reads `value` as a boolean, as `--type=bool` does; null if it is not /// one: neither a word for one nor a number that fits a C `int`. pub fn parseBool(value: ?[]const u8) ?bool { if (boolWord(value)) |b| return b; const n = parseInt(i32, value.?) catch return null; return n != 0; } /// A value read as `--type=bool-or-int` reads it. pub const BoolOrInt = union(enum) { boolean: bool, int: i32, }; /// Reads `value` as a word for true or false if it is one, and otherwise /// as an integer that fits a C `int`. pub fn parseBoolOrInt(value: ?[]const u8) IntError!BoolOrInt { if (boolWord(value)) |b| return .{ .boolean = b }; return .{ .int = try parseInt(i32, value.?) }; } /// What a path's `~` and `%(prefix)` are expanded with, passed in since /// the library reads no environment. pub const PathContext = struct { /// `$HOME`, for `~` and `~/`; null if it is unset. home: ?[]const u8 = null, /// The directory git is installed in, for `%(prefix)/`; null if /// unknown. prefix: ?[]const u8 = null, /// Finds the home directory of a named user, for `~user/`; null if /// there is no such user or nothing to ask. userHome: ?*const fn (context: ?*anyopaque, user: []const u8) ?[]const u8 = null, /// Passed to `userHome`. user_context: ?*anyopaque = null, }; /// Why a path could not be expanded. pub const PathError = error{ /// `~` or `~user` named a home directory that could not be found: /// git's "failed to expand user dir". UserHomeUnknown, /// `%(prefix)/` with no prefix to expand it with. PrefixUnknown, } || Allocator.Error; /// A path value, expanded. pub const Path = struct { /// The path, allocated with the allocator `expandPath` was given. path: []u8, /// Whether it was written `:(optional)...`, meaning the variable is to /// be taken as unset if nothing is at the path. optional: bool, }; /// Expands a path value as `--type=path` does: a leading `:(optional)` /// noted and removed, then `~/` or `~` alone made `$HOME`, `~user/` that /// user's home directory, and `%(prefix)/` the installation prefix. /// Nothing else is touched; `./%(prefix)/` is how a literal one is /// written. pub fn expandPath(gpa: Allocator, value: []const u8, context: PathContext) PathError!Path { var v = value; const optional_prefix = ":(optional)"; const optional = std.mem.startsWith(u8, v, optional_prefix); if (optional) v = v[optional_prefix.len..]; const path_prefix = "%(prefix)/"; if (std.mem.startsWith(u8, v, path_prefix)) { const prefix = context.prefix orelse return error.PrefixUnknown; return .{ .path = try std.mem.concat(gpa, u8, &.{ prefix, v[path_prefix.len - 1 ..] }), .optional = optional }; } if (v.len > 0 and v[0] == '~') { const slash = std.mem.findScalar(u8, v, '/') orelse v.len; const user = v[1..slash]; const home = if (user.len == 0) context.home orelse return error.UserHomeUnknown else if (context.userHome) |f| f(context.user_context, user) orelse return error.UserHomeUnknown else return error.UserHomeUnknown; return .{ .path = try std.mem.concat(gpa, u8, &.{ home, v[slash..] }), .optional = optional }; } return .{ .path = try gpa.dupe(u8, v), .optional = optional }; } const testing = std.testing; test "integers are read as git reads them" { try testing.expectEqual(16, try parseInt(i64, "0x10")); try testing.expectEqual(8, try parseInt(i64, "010")); try testing.expectEqual(1, try parseInt(i64, "0b1")); try testing.expectEqual(31, try parseInt(i64, "0X1F")); try testing.expectEqual(1, try parseInt(i64, "\x0b+1")); try testing.expectEqual(-1024, try parseInt(i64, "-1k")); try testing.expectEqual(1 << 30, try parseInt(i64, "1G")); try testing.expectEqual(std.math.minInt(i64), try parseInt(i64, "-9223372036854775808")); try testing.expectEqual(std.math.minInt(i64), try parseInt(i64, "-8589934592g")); try testing.expectEqual(1 << 53, try parseInt(i64, "8796093022208k")); try testing.expectError(error.OutOfRange, parseInt(i64, "9223372036854775808")); try testing.expectError(error.OutOfRange, parseInt(i64, "8589934592g")); try testing.expectError(error.OutOfRange, parseInt(i64, "99999999999999999999x")); try testing.expectError(error.OutOfRange, parseInt(i32, "2147483648")); try testing.expectEqual(std.math.minInt(i32), try parseInt(i32, "-2097152k")); for ([_][]const u8{ "", "k", "-", "+", "1 ", "1t", "1kb", "08", "1.5", "0x", "true" }) |bad| { try testing.expectError(error.InvalidUnit, parseInt(i64, bad)); } } test "booleans are read as git reads them" { try testing.expectEqual(true, parseBool(null)); try testing.expectEqual(false, parseBool("")); try testing.expectEqual(true, parseBool("tRuE")); try testing.expectEqual(false, parseBool("Off")); try testing.expectEqual(true, parseBool("-1k")); try testing.expectEqual(false, parseBool("0k")); try testing.expectEqual(null, parseBool("2147483648")); try testing.expectEqual(null, parseBool("y")); try testing.expectEqual(BoolOrInt{ .int = 1 }, try parseBoolOrInt("1")); try testing.expectEqual(BoolOrInt{ .boolean = false }, try parseBoolOrInt("")); } test "paths are expanded as git expands them" { const gpa = testing.allocator; const context: PathContext = .{ .home = "/home/me", .prefix = "/usr" }; for ([_][2][]const u8{ .{ "~", "/home/me" }, .{ "~/x", "/home/me/x" }, .{ "~//x", "/home/me//x" }, .{ "x/~/y", "x/~/y" }, .{ "%(prefix)/bin", "/usr/bin" }, .{ "%(prefix)", "%(prefix)" }, .{ "./%(prefix)/b", "./%(prefix)/b" }, .{ " ~/x", " ~/x" }, }) |case| { const p = try expandPath(gpa, case[0], context); defer gpa.free(p.path); try testing.expectEqualStrings(case[1], p.path); try testing.expect(!p.optional); } const p = try expandPath(gpa, ":(optional)~/x", context); defer gpa.free(p.path); try testing.expectEqualStrings("/home/me/x", p.path); try testing.expect(p.optional); try testing.expectError(error.UserHomeUnknown, expandPath(gpa, "~x", context)); try testing.expectError(error.UserHomeUnknown, expandPath(gpa, "~/x", .{})); }