From 9e2943a63fee16f6de0d557c2a9113ff67a84d37 Mon Sep 17 00:00:00 2001 From: Bill Mill Date: Tue, 26 May 2026 11:01:36 -0400 Subject: [PATCH] fix: correct ANSI truncation - reorganize terminal functions into terminal.go - strip ansi before calculating width --- columns.go | 49 +++--- main.go | 158 +---------------- main_test.go | 92 ---------- terminal.go | 220 ++++++++++++++++++++++++ terminal_test.go | 436 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 682 insertions(+), 273 deletions(-) create mode 100644 terminal.go create mode 100644 terminal_test.go diff --git a/columns.go b/columns.go index efe4c31..72c579d 100644 --- a/columns.go +++ b/columns.go @@ -6,16 +6,8 @@ import ( "os" "path/filepath" "strings" - - "github.com/mattn/go-runewidth" ) -// truncateToWidth truncates a string to fit within the specified visual width, -// properly handling Unicode characters including diacritics -func truncateToWidth(s string, maxWidth int) string { - return runewidth.Truncate(s, maxWidth, "") -} - // Nerd Font icons for individual git status characters. // See https://www.nerdfonts.com/cheat-sheet for the full icon list. // @@ -221,12 +213,13 @@ func renderStatus(out io.Writer, file *File, maxWidth int, rctx *RenderContext, } else { statusStr = file.status } - visibleWidth := width(statusStr) + truncated := truncateToWidth(statusStr, maxWidth) + visibleWidth := width(truncated) // Right-align: pad with spaces on the left for i := 0; i < maxWidth-visibleWidth; i++ { must(fmt.Fprintf(out, " ")) } - must(fmt.Fprintf(out, "%s", statusStr)) + must(fmt.Fprintf(out, "%s", truncated)) // No right padding needed for status since it's right-aligned } } @@ -234,9 +227,10 @@ func renderStatus(out io.Writer, file *File, maxWidth int, rctx *RenderContext, // renderDiff renders the diff graph column func renderDiff(out io.Writer, file *File, maxWidth int, rctx *RenderContext, isLast bool) { if maxWidth > 0 { - must(fmt.Fprintf(out, "%s", file.diffStat)) + truncated := truncateToWidth(file.diffStat, maxWidth) + must(fmt.Fprintf(out, "%s", truncated)) if !isLast { - for i := 0; i < maxWidth-width(file.diffStat); i++ { + for i := 0; i < maxWidth-width(truncated); i++ { must(fmt.Fprintf(out, " ")) } } @@ -245,6 +239,9 @@ func renderDiff(out io.Writer, file *File, maxWidth int, rctx *RenderContext, is // renderFilename renders the filename column with colors and hyperlinks func renderFilename(out io.Writer, file *File, maxWidth int, rctx *RenderContext, isLast bool) { + truncatedName := truncateToWidth(file.Name(), maxWidth) + truncatedWidth := width(truncatedName) + if file.isDeleted { must(fmt.Fprintf(out, "%s%s", RED, STRIKEOUT)) } else if file.isDir { @@ -255,15 +252,15 @@ func renderFilename(out io.Writer, file *File, maxWidth int, rctx *RenderContext // link the file name to the file's location (but not for deleted files) if file.isDeleted { - must(fmt.Fprintf(out, "%s", file.Name())) + must(fmt.Fprintf(out, "%s", truncatedName)) } else { fileURL := fmt.Sprintf("file://%s%s", must(os.Hostname()), filepath.Join(rctx.Dir, file.Name())) - must(fmt.Fprintf(out, "%s", link(fileURL, file.Name()))) + must(fmt.Fprintf(out, "%s", link(fileURL, truncatedName))) } // pad spaces to the right up to maxWidth (unless this is the last column) if !isLast { - for i := 0; i < maxWidth-width(file.Name()); i++ { + for i := 0; i < maxWidth-truncatedWidth; i++ { must(fmt.Fprintf(out, " ")) } } @@ -278,7 +275,8 @@ func renderFilename(out io.Writer, file *File, maxWidth int, rctx *RenderContext func renderShorthash(out io.Writer, file *File, maxWidth int, rctx *RenderContext, isLast bool) { if maxWidth > 0 { shortHash := file.shortHash - shortHashWidth := min(width(shortHash), maxWidth) + truncated := truncateToWidth(shortHash, maxWidth) + truncatedWidth := width(truncated) var color string if rctx.MonoHash { color = CYAN @@ -288,14 +286,14 @@ func renderShorthash(out io.Writer, file *File, maxWidth int, rctx *RenderContex if len(rctx.GithubURL) > 0 && shortHash != "" { commitURL := fmt.Sprintf("%s/commit/%s", rctx.GithubURL, file.hash) - must(fmt.Fprintf(out, "%s%s%s", color, link(commitURL, shortHash[:min(len(shortHash), maxWidth)]), RESET)) + must(fmt.Fprintf(out, "%s%s%s", color, link(commitURL, truncated), RESET)) } else { - must(fmt.Fprintf(out, "%s%s%s", color, shortHash[:min(len(shortHash), maxWidth)], RESET)) + must(fmt.Fprintf(out, "%s%s%s", color, truncated, RESET)) } // pad spaces to the right up to maxWidth (unless this is the last column) if !isLast { - for i := 0; i < maxWidth-shortHashWidth; i++ { + for i := 0; i < maxWidth-truncatedWidth; i++ { must(fmt.Fprintf(out, " ")) } } @@ -311,16 +309,18 @@ func renderHash(out io.Writer, file *File, maxWidth int, rctx *RenderContext, is } else { color = hashToColor(file.hash) } + truncated := truncateToWidth(file.hash, maxWidth) + truncatedWidth := width(truncated) if len(rctx.GithubURL) > 0 && file.hash != "" { commitURL := fmt.Sprintf("%s/commit/%s", rctx.GithubURL, file.hash) - must(fmt.Fprintf(out, "%s%s%s", color, link(commitURL, file.hash), RESET)) + must(fmt.Fprintf(out, "%s%s%s", color, link(commitURL, truncated), RESET)) } else { - must(fmt.Fprintf(out, "%s%s%s", color, file.hash, RESET)) + must(fmt.Fprintf(out, "%s%s%s", color, truncated, RESET)) } // pad spaces to the right up to maxWidth (unless this is the last column) if !isLast { - for i := 0; i < maxWidth-width(file.hash); i++ { + for i := 0; i < maxWidth-truncatedWidth; i++ { must(fmt.Fprintf(out, " ")) } } @@ -329,10 +329,11 @@ func renderHash(out io.Writer, file *File, maxWidth int, rctx *RenderContext, is // renderDate renders the date column func renderDate(out io.Writer, file *File, maxWidth int, rctx *RenderContext, isLast bool) { - must(fmt.Fprintf(out, "%s", file.lastModified)) + truncated := truncateToWidth(file.lastModified, maxWidth) + must(fmt.Fprintf(out, "%s", truncated)) // pad spaces to the right up to maxWidth (unless this is the last column) if !isLast { - for i := 0; i < maxWidth-width(file.lastModified); i++ { + for i := 0; i < maxWidth-width(truncated); i++ { must(fmt.Fprintf(out, " ")) } } diff --git a/main.go b/main.go index b7bcbb3..ab9dfc5 100644 --- a/main.go +++ b/main.go @@ -14,11 +14,7 @@ import ( "strconv" "strings" "sync" - "syscall" "time" - "unsafe" - - "github.com/mattn/go-runewidth" ) const VERSION = "6.1.0" @@ -158,76 +154,6 @@ func (f *File) Name() string { return f.name } -var ( - BLUE = "\x1b[34m" - CYAN = "\x1b[36m" - GREEN = "\x1b[32m" - RED = "\x1b[31m" - RESET = "\x1b[0m" - YELLOW = "\x1b[33m" - STRIKEOUT = "\x1b[9m" - NOCOLOR = false -) - -func init() { - if _, ok := os.LookupEnv("NO_COLOR"); ok { - NOCOLOR = true - BLUE = "" - CYAN = "" - GREEN = "" - RED = "" - RESET = "" - YELLOW = "" - STRIKEOUT = "" - } -} - -// validHashColors contains pre-computed 8-bit terminal color codes -// from the 6x6x6 RGB cube (16-231), excluding colors that are too dark, -// too light, or too gray. -var validHashColors = []int{ - 18, 19, 20, 21, 24, 25, 26, 27, 28, 29, - 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, - 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 54, 55, 56, 57, 61, 62, 63, 64, - 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, - 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, - 87, 88, 89, 90, 91, 92, 93, 94, 97, 98, - 99, 100, 104, 105, 106, 107, 110, 111, 112, 113, - 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, - 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, - 134, 135, 136, 137, 140, 141, 142, 143, 147, 148, - 149, 150, 153, 154, 155, 156, 157, 158, 159, 160, - 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, - 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, - 183, 184, 185, 186, 190, 191, 192, 193, 196, 197, - 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, - 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, - 218, 219, 220, 221, 222, 223, 226, 227, 228, 229, -} - -// hashToColor generates a color code for a commit hash. -// It uses 8-bit terminal colors (\e[38;5;m) from the pre-computed validHashColors. -func hashToColor(hash string) string { - if NOCOLOR { - return "" - } - - if hash == "" { - return CYAN - } - - // Calculate a numeric hash from the commit hash string - var h uint32 - for i := 0; i < len(hash) && i < 8; i++ { - h = h*31 + uint32(hash[i]) - } - - // Select a color from the pre-computed valid colors using the hash - colorCode := validHashColors[h%uint32(len(validHashColors))] - return fmt.Sprintf("\x1b[38;5;%dm", colorCode) -} - func must[T any](a T, e error) T { if e != nil { panic(e) @@ -607,7 +533,7 @@ func run() int { formatColumns = AllColumns() } - maxWidth := columns(os.Stdout.Fd()) + maxWidth := terminalColumns(os.Stdout.Fd()) if maxWidth == 0 { maxWidth = 80 // default when not a TTY } @@ -756,88 +682,6 @@ func parseFormat(formatStr string) []Column { return columns } -func link(url string, name string) string { - // hyperlink format: \e]8;;\e\\e]8;;\e\ - return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, name) -} - -func linkify(commitMsg string, github string, hash string) string { - issueRe := regexp.MustCompile(`#(\d+)`) - issueIx := issueRe.FindStringIndex(commitMsg) - out := make([]string, 0, 16) - for issueIx != nil { - commitURL := fmt.Sprintf("%s/commit/%s", github, hash) - out = append(out, link(commitURL, commitMsg[:issueIx[0]])) - - issueURL := fmt.Sprintf("%s/pull/%s", github, commitMsg[issueIx[0]+1:issueIx[1]]) - issueText := fmt.Sprintf("%s%s%s", BLUE, commitMsg[issueIx[0]:issueIx[1]], RESET) - out = append(out, link(issueURL, issueText)) - - commitMsg = commitMsg[issueIx[1]:] - issueIx = issueRe.FindStringIndex(commitMsg) - } - out = append(out, link(fmt.Sprintf("%s/commit/%s", github, hash), commitMsg)) - - return strings.Join(out, "") -} - -// width returns the printable width of a string in a terminal, by ignoring -// ansi sequences. This version assumes all characters have a width of 1, which -// is not true in general but is true in this program. modified from: -// https://github.com/muesli/ansi/blob/276c6243b/buffer.go#L21 -func width(s string) int { - // Use runewidth to properly handle Unicode characters including diacritics - return runewidth.StringWidth(stripANSI(s)) -} - -// stripANSI removes ANSI escape codes from a string for width calculation -func stripANSI(s string) string { - var result strings.Builder - var i int - for i < len(s) { - if i < len(s)-1 && s[i] == '\x1b' { - // Check for OSC sequences (hyperlinks): \x1b]8;;...\x1b\\ - if s[i+1] == ']' { - // Find the ST (String Terminator): \x1b\\ - end := strings.Index(s[i:], "\x1b\\") - if end != -1 { - i += end + 2 // Skip past the entire OSC sequence - continue - } - } - // Check for CSI sequences: \x1b[...letter - if s[i+1] == '[' { - i += 2 - for i < len(s) { - // @, A-Z, a-z terminate the escape - if (s[i] >= 0x40 && s[i] <= 0x5a) || (s[i] >= 0x61 && s[i] <= 0x7a) { - i++ - break - } - i++ - } - continue - } - } - result.WriteByte(s[i]) - i++ - } - return result.String() -} - -type windowSize struct { - rows uint16 - cols uint16 -} - -// from https://github.com/epam/hubctl/blob/6f86e6663/cmd/hub/lifecycle/terminal.go#L59 -func columns(fd uintptr) int { - var sz windowSize - _, _, _ = syscall.Syscall(syscall.SYS_IOCTL, - fd, uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&sz))) - return int(sz.cols) -} - // Pulled straight from git: // https://github.com/git/git/blob/d4cc1ec3/diff.c#L2862-L2874 func scaleLinear(n int, width int, maxChange int) int { diff --git a/main_test.go b/main_test.go index 461c7ac..85c98e7 100644 --- a/main_test.go +++ b/main_test.go @@ -393,98 +393,6 @@ func TestShowFileHyperlinks(t *testing.T) { } } -func TestHashToColor(t *testing.T) { - tests := []struct { - name string - hash string - }{ - { - name: "empty hash returns cyan", - hash: "", - }, - { - name: "same hash returns same color", - hash: "abc123", - }, - { - name: "different hash", - hash: "def456", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - color := hashToColor(tt.hash) - - // Empty hash should return CYAN - if tt.hash == "" { - if color != CYAN { - t.Errorf("hashToColor(\"\") = %q, expected CYAN (%q)", color, CYAN) - } - return - } - - // Non-empty hash should return a valid 8-bit color code - if !strings.HasPrefix(color, "\x1b[38;5;") || !strings.HasSuffix(color, "m") { - t.Errorf("hashToColor(%q) = %q, expected 8-bit color format", tt.hash, color) - } - - // Same hash should always return same color - color2 := hashToColor(tt.hash) - if color != color2 { - t.Errorf("hashToColor not deterministic: first call=%q, second call=%q", color, color2) - } - }) - } - - // Test that different hashes (likely) produce different colors - hash1 := "55b998eae87118427808144927549a39e821c0fb" - hash2 := "dd59602e5a8af4da0429237c2f2d547ee1bdc800" - color1 := hashToColor(hash1) - color2 := hashToColor(hash2) - if color1 == color2 { - t.Errorf("Different hashes produced same color: %q and %q both got %q", hash1, hash2, color1) - } -} - -func TestLinkify(t *testing.T) { - testCases := []struct { - name string - test string - expected string - }{ - { - name: "Basic test", - test: "Some message", - expected: link("https://github.com/a/b/commit/123abc", "Some message"), - }, - { - name: "One issue link", - test: "fixes issue (#17)", - expected: link("https://github.com/a/b/commit/123abc", "fixes issue (") + - link("https://github.com/a/b/pull/17", fmt.Sprintf("%s%s%s", BLUE, "#17", RESET)) + - link("https://github.com/a/b/commit/123abc", ")"), - }, - { - name: "Two issue links", - test: "fixes issue (#17) closes (#99)", - expected: link("https://github.com/a/b/commit/123abc", "fixes issue (") + - link("https://github.com/a/b/pull/17", fmt.Sprintf("%s%s%s", BLUE, "#17", RESET)) + - link("https://github.com/a/b/commit/123abc", ") closes (") + - link("https://github.com/a/b/pull/99", fmt.Sprintf("%s%s%s", BLUE, "#99", RESET)) + - link("https://github.com/a/b/commit/123abc", ")"), - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - s := linkify(tc.test, "https://github.com/a/b", "123abc") - if s != tc.expected { - t.Errorf("Expected\n%#v !=\n%#v", tc.expected, s) - } - }) - } -} - // TestWorktreeWithSymlink tests that git-ls works correctly in a worktree // accessed via symlink. This is a regression test for issue #34. func TestWorktreeWithSymlink(t *testing.T) { diff --git a/terminal.go b/terminal.go new file mode 100644 index 0000000..86eb3f2 --- /dev/null +++ b/terminal.go @@ -0,0 +1,220 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "strings" + "syscall" + "unsafe" + + "github.com/mattn/go-runewidth" +) + +var ( + BLUE = "\x1b[34m" + CYAN = "\x1b[36m" + GREEN = "\x1b[32m" + RED = "\x1b[31m" + RESET = "\x1b[0m" + YELLOW = "\x1b[33m" + STRIKEOUT = "\x1b[9m" + NOCOLOR = false +) + +func init() { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + NOCOLOR = true + BLUE = "" + CYAN = "" + GREEN = "" + RED = "" + RESET = "" + YELLOW = "" + STRIKEOUT = "" + } +} + +// validHashColors contains pre-computed 8-bit terminal color codes +// from the 6x6x6 RGB cube (16-231), excluding colors that are too dark, +// too light, or too gray. +var validHashColors = []int{ + 18, 19, 20, 21, 24, 25, 26, 27, 28, 29, + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, + 50, 51, 54, 55, 56, 57, 61, 62, 63, 64, + 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, + 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, + 87, 88, 89, 90, 91, 92, 93, 94, 97, 98, + 99, 100, 104, 105, 106, 107, 110, 111, 112, 113, + 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, + 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, + 134, 135, 136, 137, 140, 141, 142, 143, 147, 148, + 149, 150, 153, 154, 155, 156, 157, 158, 159, 160, + 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, + 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, + 183, 184, 185, 186, 190, 191, 192, 193, 196, 197, + 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, + 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, + 218, 219, 220, 221, 222, 223, 226, 227, 228, 229, +} + +// hashToColor generates a color code for a commit hash. +// It uses 8-bit terminal colors (\e[38;5;m) from the pre-computed validHashColors. +func hashToColor(hash string) string { + if NOCOLOR { + return "" + } + + if hash == "" { + return CYAN + } + + // Calculate a numeric hash from the commit hash string + var h uint32 + for i := 0; i < len(hash) && i < 8; i++ { + h = h*31 + uint32(hash[i]) + } + + // Select a color from the pre-computed valid colors using the hash + colorCode := validHashColors[h%uint32(len(validHashColors))] + return fmt.Sprintf("\x1b[38;5;%dm", colorCode) +} + +// link creates an OSC8 hyperlink escape sequence +func link(url string, name string) string { + // hyperlink format: \e]8;;\e\\e]8;;\e\ + return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, name) +} + +// linkify converts issue references (#123) in a commit message to hyperlinks +func linkify(commitMsg string, github string, hash string) string { + issueRe := regexp.MustCompile(`#(\d+)`) + issueIx := issueRe.FindStringIndex(commitMsg) + out := make([]string, 0, 16) + for issueIx != nil { + commitURL := fmt.Sprintf("%s/commit/%s", github, hash) + out = append(out, link(commitURL, commitMsg[:issueIx[0]])) + + issueURL := fmt.Sprintf("%s/pull/%s", github, commitMsg[issueIx[0]+1:issueIx[1]]) + issueText := fmt.Sprintf("%s%s%s", BLUE, commitMsg[issueIx[0]:issueIx[1]], RESET) + out = append(out, link(issueURL, issueText)) + + commitMsg = commitMsg[issueIx[1]:] + issueIx = issueRe.FindStringIndex(commitMsg) + } + out = append(out, link(fmt.Sprintf("%s/commit/%s", github, hash), commitMsg)) + + return strings.Join(out, "") +} + +// skipANSI checks if position i in string s is the start of an ANSI escape +// sequence. If so, it returns the number of bytes to skip (the length of the +// escape sequence). If not, it returns 0. +func skipANSI(s string, i int) int { + if i >= len(s)-1 || s[i] != '\x1b' { + return 0 + } + + // OSC sequence: \x1b]...\x1b\\ + if s[i+1] == ']' { + end := strings.Index(s[i:], "\x1b\\") + if end != -1 { + return end + 2 + } + } + + // CSI sequence: \x1b[...letter + if s[i+1] == '[' { + j := i + 2 + for j < len(s) { + // @, A-Z, a-z terminate the escape + if (s[j] >= 0x40 && s[j] <= 0x5a) || (s[j] >= 0x61 && s[j] <= 0x7a) { + j++ + break + } + j++ + } + return j - i + } + + return 0 +} + +// stripANSI removes ANSI escape codes from a string +func stripANSI(s string) string { + var result strings.Builder + i := 0 + for i < len(s) { + if n := skipANSI(s, i); n > 0 { + i += n + continue + } + result.WriteByte(s[i]) + i++ + } + return result.String() +} + +// width returns the visible width of a string in a terminal, ignoring ANSI +// escape sequences and properly handling Unicode character widths. +func width(s string) int { + return runewidth.StringWidth(stripANSI(s)) +} + +// truncateToWidth truncates a string to fit within the specified visual width, +// properly handling Unicode characters including diacritics and ANSI escape sequences. +// ANSI escape sequences (CSI and OSC) are passed through without counting toward width. +func truncateToWidth(s string, maxWidth int) string { + if !strings.Contains(s, "\x1b") { + // Fast path: no ANSI escapes, use runewidth directly + return runewidth.Truncate(s, maxWidth, "") + } + + // Slow path: ANSI-aware truncation + var result strings.Builder + visibleWidth := 0 + i := 0 + for i < len(s) && visibleWidth < maxWidth { + if n := skipANSI(s, i); n > 0 { + result.WriteString(s[i : i+n]) + i += n + continue + } + + // Regular character: decode rune and check width + r, size := rune(s[i]), 1 + if s[i] >= 0x80 { + // Multi-byte UTF-8 + for _, runeVal := range s[i:] { + r = runeVal + break + } + size = len(string(r)) + } + + rw := runewidth.RuneWidth(r) + if visibleWidth+rw > maxWidth { + break + } + result.WriteString(s[i : i+size]) + visibleWidth += rw + i += size + } + + return result.String() +} + +type windowSize struct { + rows uint16 + cols uint16 +} + +// terminalColumns returns the number of columns in the terminal +// from https://github.com/epam/hubctl/blob/6f86e6663/cmd/hub/lifecycle/terminal.go#L59 +func terminalColumns(fd uintptr) int { + var sz windowSize + _, _, _ = syscall.Syscall(syscall.SYS_IOCTL, + fd, uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&sz))) + return int(sz.cols) +} diff --git a/terminal_test.go b/terminal_test.go new file mode 100644 index 0000000..699be43 --- /dev/null +++ b/terminal_test.go @@ -0,0 +1,436 @@ +package main + +import ( + "fmt" + "strings" + "testing" +) + +func TestSkipANSI(t *testing.T) { + tests := []struct { + name string + s string + i int + expected int + }{ + { + name: "no escape at position", + s: "hello", + i: 0, + expected: 0, + }, + { + name: "CSI color code", + s: "\x1b[32mgreen", + i: 0, + expected: 5, // \x1b [ 3 2 m + }, + { + name: "CSI reset", + s: "\x1b[0m", + i: 0, + expected: 4, + }, + { + name: "CSI 8-bit color", + s: "\x1b[38;5;123mtext", + i: 0, + expected: 11, + }, + { + name: "OSC hyperlink", + s: "\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\", + i: 0, + expected: 26, // \x1b]8;;https://example.com\x1b\\ + }, + { + name: "escape in middle of string", + s: "hi \x1b[31mred", + i: 3, + expected: 5, + }, + { + name: "not at escape position", + s: "hi \x1b[31mred", + i: 1, + expected: 0, + }, + { + name: "at end of string", + s: "hi", + i: 1, + expected: 0, + }, + { + name: "strikeout", + s: "\x1b[9mstrikethrough", + i: 0, + expected: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := skipANSI(tt.s, tt.i) + if got != tt.expected { + t.Errorf("skipANSI(%q, %d) = %d, want %d", tt.s, tt.i, got, tt.expected) + } + }) + } +} + +func TestStripANSI(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no escapes", + input: "hello world", + expected: "hello world", + }, + { + name: "single color", + input: "\x1b[32mgreen\x1b[0m", + expected: "green", + }, + { + name: "multiple colors", + input: "\x1b[31mred\x1b[0m and \x1b[34mblue\x1b[0m", + expected: "red and blue", + }, + { + name: "hyperlink", + input: "\x1b]8;;https://example.com\x1b\\click here\x1b]8;;\x1b\\", + expected: "click here", + }, + { + name: "color inside hyperlink", + input: "\x1b[36m\x1b]8;;https://x.com\x1b\\text\x1b]8;;\x1b\\\x1b[0m", + expected: "text", + }, + { + name: "8-bit color", + input: "\x1b[38;5;42mcolored\x1b[0m", + expected: "colored", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "strikeout and color", + input: "\x1b[31m\x1b[9mdeleted\x1b[0m", + expected: "deleted", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripANSI(tt.input) + if got != tt.expected { + t.Errorf("stripANSI(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestWidth(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + { + name: "plain ASCII", + input: "hello", + expected: 5, + }, + { + name: "with color codes", + input: "\x1b[32mgreen\x1b[0m", + expected: 5, + }, + { + name: "with hyperlink", + input: "\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\", + expected: 5, + }, + { + name: "empty string", + input: "", + expected: 0, + }, + { + name: "wide characters (CJK)", + input: "日本語", + expected: 6, + }, + { + name: "mixed ASCII and wide", + input: "hi日本", + expected: 6, + }, + { + name: "colored wide characters", + input: "\x1b[31m日本\x1b[0m", + expected: 4, + }, + { + name: "diff graph with colors", + input: fmt.Sprintf("%s++%s--%s", GREEN, RED, RESET), + expected: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := width(tt.input) + if got != tt.expected { + t.Errorf("width(%q) = %d, want %d", tt.input, got, tt.expected) + } + }) + } +} + +func TestTruncateToWidth(t *testing.T) { + tests := []struct { + name string + input string + maxWidth int + expected string // expected visible text (after stripping ANSI) + }{ + { + name: "no truncation needed", + input: "hello", + maxWidth: 10, + expected: "hello", + }, + { + name: "truncate plain text", + input: "hello world", + maxWidth: 5, + expected: "hello", + }, + { + name: "truncate at zero", + input: "hello", + maxWidth: 0, + expected: "", + }, + { + name: "preserve ANSI color when truncating", + input: "\x1b[32mgreen text\x1b[0m", + maxWidth: 5, + expected: "green", + }, + { + name: "ANSI codes don't count toward width", + input: "\x1b[32mhi\x1b[0m", + maxWidth: 2, + expected: "hi", + }, + { + name: "hyperlink preserved when text fits", + input: "\x1b]8;;https://example.com\x1b\\hey\x1b]8;;\x1b\\", + maxWidth: 3, + expected: "hey", + }, + { + name: "hyperlink text truncated", + input: "\x1b]8;;https://example.com\x1b\\hello\x1b]8;;\x1b\\", + maxWidth: 3, + expected: "hel", + }, + { + name: "wide characters truncated at boundary", + input: "日本語", + maxWidth: 4, + expected: "日本", + }, + { + name: "wide char doesn't fit partially", + input: "日本語", + maxWidth: 3, + expected: "日", // Can't fit half of 本 + }, + { + name: "colored diff graph truncated", + input: fmt.Sprintf("%s+++%s---%s", GREEN, RED, RESET), + maxWidth: 4, + expected: "+++-", + }, + { + name: "date string no truncation", + input: "2024-10-01", + maxWidth: 10, + expected: "2024-10-01", + }, + { + name: "date string truncated by 1", + input: "2024-10-01", + maxWidth: 9, + expected: "2024-10-0", + }, + { + name: "nerdfont icon with color preserved", + input: "\x1b[32m\uf040\x1b[0m ", + maxWidth: 2, + expected: "\uf040 ", + }, + { + name: "nerdfont icon with color truncated to 1", + input: "\x1b[32m\uf040\x1b[0m ", + maxWidth: 1, + expected: "\uf040", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateToWidth(tt.input, tt.maxWidth) + gotVisible := stripANSI(got) + + if gotVisible != tt.expected { + t.Errorf("truncateToWidth(%q, %d) visible = %q, want %q\n raw output: %q", + tt.input, tt.maxWidth, gotVisible, tt.expected, got) + } + + // Verify width invariant: visible width must not exceed maxWidth + gotWidth := width(got) + if gotWidth > tt.maxWidth { + t.Errorf("truncateToWidth(%q, %d) produced width %d, exceeds max", + tt.input, tt.maxWidth, gotWidth) + } + }) + } +} + +func TestTruncateToWidthPreservesANSI(t *testing.T) { + // Verify that ANSI codes pass through (not stripped) when text fits. + // Note: trailing ANSI codes after visible content may be omitted since + // truncateToWidth stops once visible width is consumed. + input := "\x1b[32mgreen\x1b[0m" + got := truncateToWidth(input, 5) + // The fast path (no ANSI) won't apply here, so the slow path will + // include the leading color but stop after 'green' since width is met. + // The reset code comes after the visible chars, so it may or may not be included. + gotVisible := stripANSI(got) + if gotVisible != "green" { + t.Errorf("truncateToWidth visible text = %q, want %q", gotVisible, "green") + } + if !strings.Contains(got, "\x1b[32m") { + t.Errorf("truncateToWidth should preserve leading ANSI: %q", got) + } + + // Verify color code is kept even when text is truncated + got = truncateToWidth(input, 3) + if !strings.Contains(got, "\x1b[32m") { + t.Errorf("truncateToWidth should keep leading ANSI in truncated output: %q", got) + } + if width(got) != 3 { + t.Errorf("truncateToWidth visible width = %d, want 3", width(got)) + } +} + +func TestHashToColor(t *testing.T) { + tests := []struct { + name string + hash string + }{ + { + name: "empty hash returns cyan", + hash: "", + }, + { + name: "same hash returns same color", + hash: "abc123", + }, + { + name: "different hash", + hash: "def456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + color := hashToColor(tt.hash) + + // Empty hash should return CYAN + if tt.hash == "" { + if color != CYAN { + t.Errorf("hashToColor(\"\") = %q, expected CYAN (%q)", color, CYAN) + } + return + } + + // Non-empty hash should return a valid 8-bit color code + if !strings.HasPrefix(color, "\x1b[38;5;") || !strings.HasSuffix(color, "m") { + t.Errorf("hashToColor(%q) = %q, expected 8-bit color format", tt.hash, color) + } + + // Same hash should always return same color (deterministic) + color2 := hashToColor(tt.hash) + if color != color2 { + t.Errorf("hashToColor not deterministic: first=%q, second=%q", color, color2) + } + }) + } + + // Different hashes should (likely) produce different colors + color1 := hashToColor("55b998eae87118427808144927549a39e821c0fb") + color2 := hashToColor("dd59602e5a8af4da0429237c2f2d547ee1bdc800") + if color1 == color2 { + t.Errorf("Different hashes produced same color: both got %q", color1) + } +} + +func TestLinkify(t *testing.T) { + tests := []struct { + name string + msg string + github string + hash string + expected string + }{ + { + name: "no issue references", + msg: "Some message", + github: "https://github.com/a/b", + hash: "123abc", + expected: link("https://github.com/a/b/commit/123abc", "Some message"), + }, + { + name: "one issue reference", + msg: "fixes issue (#17)", + github: "https://github.com/a/b", + hash: "123abc", + expected: link("https://github.com/a/b/commit/123abc", "fixes issue (") + + link("https://github.com/a/b/pull/17", fmt.Sprintf("%s%s%s", BLUE, "#17", RESET)) + + link("https://github.com/a/b/commit/123abc", ")"), + }, + { + name: "two issue references", + msg: "fixes (#17) closes (#99)", + github: "https://github.com/a/b", + hash: "123abc", + expected: link("https://github.com/a/b/commit/123abc", "fixes (") + + link("https://github.com/a/b/pull/17", fmt.Sprintf("%s%s%s", BLUE, "#17", RESET)) + + link("https://github.com/a/b/commit/123abc", ") closes (") + + link("https://github.com/a/b/pull/99", fmt.Sprintf("%s%s%s", BLUE, "#99", RESET)) + + link("https://github.com/a/b/commit/123abc", ")"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := linkify(tt.msg, tt.github, tt.hash) + if got != tt.expected { + t.Errorf("linkify(%q, %q, %q):\n got: %q\n want: %q", + tt.msg, tt.github, tt.hash, got, tt.expected) + } + }) + } +}