// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! How many columns of a terminal text takes, as git counts them for //! `%<()` 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])); } }