Something went wrong. Try again.
Reading and writing git repositories in Zig, without running git.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! How many columns of a terminal text takes, as git counts them for//! `%<(<n>)` and its relatives and for `%w(...)`: UTF-8 decoded, combining//! marks and other characters of no width counted as none, East Asian wide//! and fullwidth characters as two, everything else as one.//!//! Control characters are git's own case: padding counts them as no//! columns, and wrapping as one column fewer (`Char.width` is null for//! them, so each caller decides). A byte that does not begin a whole UTF-8//! sequence counts as one column, as git counts it.//!//! The tables are generated from the Unicode Character Database by//! `tools/gen_width.zig` (see `unicode_version`): no columns for//! General_Category Mn, Me and Cf, except U+00AD SOFT HYPHEN, and for the//! Hangul medial vowels and final consonants U+1160..U+11FF; two for//! East_Asian_Width W and F. These match git 2.56 at every code point but//! U+FFFE and U+FFFF, which git does not take for UTF-8 and counts a column//! a byte; a git built with another Unicode version differs for the//! characters added or changed since.
const std = @import("std");
/// One character: how many bytes it takes and how many columns.pub const Char = struct { len: u3, /// Columns, or null for a control character. width: ?u2,};
/// The character at the start of `text`, which must not be empty.pub fn next(text: []const u8) Char { const b = text[0]; if (b < 0x80) { if (b < 0x20 or b == 0x7f) return .{ .len = 1, .width = null }; return .{ .len = 1, .width = 1 }; } const len = std.unicode.utf8ByteSequenceLength(b) catch return .{ .len = 1, .width = 1 }; if (text.len < len) return .{ .len = 1, .width = 1 }; const cp = std.unicode.utf8Decode(text[0..len]) catch return .{ .len = 1, .width = 1 }; if (cp < 0xa0) return .{ .len = len, .width = null }; return .{ .len = len, .width = codepointWidth(cp) };}
/// The columns `text` takes, control characters counting as none: the/// width padding measures.pub fn of(text: []const u8) usize { var n: usize = 0; var i: usize = 0; while (i < text.len) { // Escape sequences a color placeholder wrote take no columns. if (text[i] == 0x1b) { if (escapeLength(text[i..])) |l| { i += l; continue; } } const c = next(text[i..]); n += c.width orelse 0; i += c.len; } return n;}
/// The length of the color escape sequence `text` begins with, if it/// begins with one.pub fn escapeLength(text: []const u8) ?usize { if (text.len < 2 or text[0] != 0x1b or text[1] != '[') return null; var i: usize = 2; while (i < text.len and (std.ascii.isDigit(text[i]) or text[i] == ';')) i += 1; if (i < text.len and text[i] == 'm') return i + 1; return null;}
const tables = @import("width_tables.zig");const Range = tables.Range;
/// The Unicode version the tables of widths come from.pub const unicode_version = tables.unicode_version;
fn inTable(cp: u21, table: []const Range) bool { var lo: usize = 0; var hi: usize = table.len; while (lo < hi) { const mid = (lo + hi) / 2; if (cp < table[mid][0]) { hi = mid; } else if (cp > table[mid][1]) { lo = mid + 1; } else return true; } return false;}
/// The columns a code point at or past U+00A0 takes.pub fn codepointWidth(cp: u21) u2 { if (inTable(cp, &tables.zero_width)) return 0; if (inTable(cp, &tables.double_width)) return 2; return 1;}
test "widths" { try std.testing.expectEqual(10, of("Merge side")); try std.testing.expectEqual(6, of("\xe4\xb8\xad\xe4\xb8\xad\xe4\xb8\xad")); try std.testing.expectEqual(1, of("e\xcc\x81")); try std.testing.expectEqual(0, of("\x01\t")); try std.testing.expectEqual(1, of("\x80")); try std.testing.expectEqual(7, of("\x1b[33m8fbe330\x1b[m")); for (tables.zero_width[1..], 0..) |r, i| try std.testing.expect(r[0] > tables.zero_width[i][1] + 1); for (tables.double_width[1..], 0..) |r, i| try std.testing.expect(r[0] > tables.double_width[i][1] + 1);}
test "widths by class" { // Code points from each class, and either side of some range edges. const cases = [_]struct { u21, u2 }{ .{ 0x00a0, 1 }, .{ 0x00ad, 1 }, // no-break space; soft hyphen, Cf but one column .{ 0x0300, 0 }, .{ 0x036f, 0 }, .{ 0x0370, 1 }, // combining marks, Mn .{ 0x0488, 0 }, // combining cyrillic hundred thousands sign, Me .{ 0x0903, 1 }, // devanagari sign visarga, Mc .{ 0x0600, 0 }, .{ 0x200b, 0 }, .{ 0x200d, 0 }, .{ 0xfeff, 0 }, .{ 0xe0001, 0 }, // Cf .{ 0x2065, 1 }, // unassigned, between Cf ranges .{ 0x115f, 2 }, .{ 0x1160, 0 }, .{ 0x11ff, 0 }, .{ 0x1200, 1 }, // Hangul jamo .{ 0xd7b0, 1 }, // Hangul jamo extended-B, Lo .{ 0x302a, 0 }, // ideographic level tone mark: Mn and W, so none .{ 0x3000, 2 }, .{ 0xff01, 2 }, .{ 0xff60, 2 }, .{ 0xff61, 1 }, // fullwidth; halfwidth .{ 0x4e2d, 2 }, .{ 0x4dc0, 2 }, .{ 0x9fff, 2 }, .{ 0xa4cf, 1 }, .{ 0x1f004, 2 }, .{ 0x1f3fb, 2 }, .{ 0x1f1e6, 1 }, // emoji; regional indicator .{ 0x1fae9, 2 }, .{ 0x1faea, 2 }, .{ 0x1e6e3, 0 }, // added in Unicode 16.0 and 17.0 .{ 0x2a6e0, 2 }, .{ 0x3fffd, 2 }, .{ 0x3fffe, 1 }, // unassigned, W by default .{ 0xe000, 1 }, .{ 0x10ffff, 1 }, // private use; noncharacter }; for (cases) |c| { errdefer std.debug.print("U+{X:0>4}\n", .{c[0]}); try std.testing.expectEqual(c[1], codepointWidth(c[0])); }}