diff --git a/README.md b/README.md index e9a513c..95a02d9 100644 --- a/README.md +++ b/README.md @@ -133,33 +133,39 @@ goarch: arm64 pkg: github.com/clipperhouse/displaywidth/comparison cpu: Apple M2 -BenchmarkString_Mixed/clipperhouse/displaywidth-8 10326 ns/op 163.37 MB/s 0 B/op 0 allocs/op -BenchmarkString_Mixed/mattn/go-runewidth-8 14415 ns/op 117.03 MB/s 0 B/op 0 allocs/op -BenchmarkString_Mixed/rivo/uniseg-8 19343 ns/op 87.21 MB/s 0 B/op 0 allocs/op +BenchmarkString_Mixed/clipperhouse/displaywidth-8 10400 ns/op 162.21 MB/s 0 B/op 0 allocs/op +BenchmarkString_Mixed/mattn/go-runewidth-8 14296 ns/op 118.00 MB/s 0 B/op 0 allocs/op +BenchmarkString_Mixed/rivo/uniseg-8 19770 ns/op 85.33 MB/s 0 B/op 0 allocs/op -BenchmarkString_EastAsian/clipperhouse/displaywidth-8 10561 ns/op 159.74 MB/s 0 B/op 0 allocs/op -BenchmarkString_EastAsian/mattn/go-runewidth-8 23790 ns/op 70.91 MB/s 0 B/op 0 allocs/op -BenchmarkString_EastAsian/rivo/uniseg-8 19322 ns/op 87.31 MB/s 0 B/op 0 allocs/op +BenchmarkString_EastAsian/clipperhouse/displaywidth-8 10593 ns/op 159.26 MB/s 0 B/op 0 allocs/op +BenchmarkString_EastAsian/mattn/go-runewidth-8 23980 ns/op 70.35 MB/s 0 B/op 0 allocs/op +BenchmarkString_EastAsian/rivo/uniseg-8 19777 ns/op 85.30 MB/s 0 B/op 0 allocs/op -BenchmarkString_ASCII/clipperhouse/displaywidth-8 1033 ns/op 123.88 MB/s 0 B/op 0 allocs/op -BenchmarkString_ASCII/mattn/go-runewidth-8 1168 ns/op 109.59 MB/s 0 B/op 0 allocs/op -BenchmarkString_ASCII/rivo/uniseg-8 1585 ns/op 80.74 MB/s 0 B/op 0 allocs/op +BenchmarkString_ASCII/clipperhouse/displaywidth-8 1032 ns/op 124.09 MB/s 0 B/op 0 allocs/op +BenchmarkString_ASCII/mattn/go-runewidth-8 1162 ns/op 110.16 MB/s 0 B/op 0 allocs/op +BenchmarkString_ASCII/rivo/uniseg-8 1586 ns/op 80.69 MB/s 0 B/op 0 allocs/op -BenchmarkString_Emoji/clipperhouse/displaywidth-8 3034 ns/op 238.61 MB/s 0 B/op 0 allocs/op -BenchmarkString_Emoji/mattn/go-runewidth-8 4797 ns/op 150.94 MB/s 0 B/op 0 allocs/op -BenchmarkString_Emoji/rivo/uniseg-8 6612 ns/op 109.50 MB/s 0 B/op 0 allocs/op +BenchmarkString_Emoji/clipperhouse/displaywidth-8 3017 ns/op 240.01 MB/s 0 B/op 0 allocs/op +BenchmarkString_Emoji/mattn/go-runewidth-8 4745 ns/op 152.58 MB/s 0 B/op 0 allocs/op +BenchmarkString_Emoji/rivo/uniseg-8 6745 ns/op 107.34 MB/s 0 B/op 0 allocs/op -BenchmarkRune_Mixed/clipperhouse/displaywidth-8 3343 ns/op 504.67 MB/s 0 B/op 0 allocs/op -BenchmarkRune_Mixed/mattn/go-runewidth-8 5414 ns/op 311.62 MB/s 0 B/op 0 allocs/op +BenchmarkRune_Mixed/clipperhouse/displaywidth-8 3381 ns/op 498.90 MB/s 0 B/op 0 allocs/op +BenchmarkRune_Mixed/mattn/go-runewidth-8 5383 ns/op 313.41 MB/s 0 B/op 0 allocs/op -BenchmarkRune_EastAsian/clipperhouse/displaywidth-8 3393 ns/op 497.17 MB/s 0 B/op 0 allocs/op -BenchmarkRune_EastAsian/mattn/go-runewidth-8 15312 ns/op 110.17 MB/s 0 B/op 0 allocs/op +BenchmarkRune_EastAsian/clipperhouse/displaywidth-8 3395 ns/op 496.96 MB/s 0 B/op 0 allocs/op +BenchmarkRune_EastAsian/mattn/go-runewidth-8 15645 ns/op 107.83 MB/s 0 B/op 0 allocs/op -BenchmarkRune_ASCII/clipperhouse/displaywidth-8 256.9 ns/op 498.32 MB/s 0 B/op 0 allocs/op -BenchmarkRune_ASCII/mattn/go-runewidth-8 265.7 ns/op 481.75 MB/s 0 B/op 0 allocs/op +BenchmarkRune_ASCII/clipperhouse/displaywidth-8 257.8 ns/op 496.57 MB/s 0 B/op 0 allocs/op +BenchmarkRune_ASCII/mattn/go-runewidth-8 267.3 ns/op 478.89 MB/s 0 B/op 0 allocs/op -BenchmarkRune_Emoji/clipperhouse/displaywidth-8 1336 ns/op 541.96 MB/s 0 B/op 0 allocs/op -BenchmarkRune_Emoji/mattn/go-runewidth-8 2304 ns/op 314.23 MB/s 0 B/op 0 allocs/op +BenchmarkRune_Emoji/clipperhouse/displaywidth-8 1338 ns/op 541.24 MB/s 0 B/op 0 allocs/op +BenchmarkRune_Emoji/mattn/go-runewidth-8 2287 ns/op 316.58 MB/s 0 B/op 0 allocs/op + +BenchmarkTruncateWithTail/clipperhouse/displaywidth-8 3689 ns/op 47.98 MB/s 192 B/op 14 allocs/op +BenchmarkTruncateWithTail/mattn/go-runewidth-8 8069 ns/op 21.93 MB/s 192 B/op 14 allocs/op + +BenchmarkTruncateWithoutTail/clipperhouse/displaywidth-8 3457 ns/op 66.24 MB/s 0 B/op 0 allocs/op +BenchmarkTruncateWithoutTail/mattn/go-runewidth-8 10441 ns/op 21.93 MB/s 0 B/op 0 allocs/op ``` Here are some notes on [how to make Unicode things fast](https://clipperhouse.com/go-unicode/). diff --git a/comparison/README.md b/comparison/README.md index e6cd0fe..d21b970 100644 --- a/comparison/README.md +++ b/comparison/README.md @@ -20,31 +20,37 @@ goarch: arm64 pkg: github.com/clipperhouse/displaywidth/comparison cpu: Apple M2 -BenchmarkString_Mixed/clipperhouse/displaywidth-8 10326 ns/op 163.37 MB/s 0 B/op 0 allocs/op -BenchmarkString_Mixed/mattn/go-runewidth-8 14415 ns/op 117.03 MB/s 0 B/op 0 allocs/op -BenchmarkString_Mixed/rivo/uniseg-8 19343 ns/op 87.21 MB/s 0 B/op 0 allocs/op +BenchmarkString_Mixed/clipperhouse/displaywidth-8 10400 ns/op 162.21 MB/s 0 B/op 0 allocs/op +BenchmarkString_Mixed/mattn/go-runewidth-8 14296 ns/op 118.00 MB/s 0 B/op 0 allocs/op +BenchmarkString_Mixed/rivo/uniseg-8 19770 ns/op 85.33 MB/s 0 B/op 0 allocs/op -BenchmarkString_EastAsian/clipperhouse/displaywidth-8 10561 ns/op 159.74 MB/s 0 B/op 0 allocs/op -BenchmarkString_EastAsian/mattn/go-runewidth-8 23790 ns/op 70.91 MB/s 0 B/op 0 allocs/op -BenchmarkString_EastAsian/rivo/uniseg-8 19322 ns/op 87.31 MB/s 0 B/op 0 allocs/op +BenchmarkString_EastAsian/clipperhouse/displaywidth-8 10593 ns/op 159.26 MB/s 0 B/op 0 allocs/op +BenchmarkString_EastAsian/mattn/go-runewidth-8 23980 ns/op 70.35 MB/s 0 B/op 0 allocs/op +BenchmarkString_EastAsian/rivo/uniseg-8 19777 ns/op 85.30 MB/s 0 B/op 0 allocs/op -BenchmarkString_ASCII/clipperhouse/displaywidth-8 1033 ns/op 123.88 MB/s 0 B/op 0 allocs/op -BenchmarkString_ASCII/mattn/go-runewidth-8 1168 ns/op 109.59 MB/s 0 B/op 0 allocs/op -BenchmarkString_ASCII/rivo/uniseg-8 1585 ns/op 80.74 MB/s 0 B/op 0 allocs/op +BenchmarkString_ASCII/clipperhouse/displaywidth-8 1032 ns/op 124.09 MB/s 0 B/op 0 allocs/op +BenchmarkString_ASCII/mattn/go-runewidth-8 1162 ns/op 110.16 MB/s 0 B/op 0 allocs/op +BenchmarkString_ASCII/rivo/uniseg-8 1586 ns/op 80.69 MB/s 0 B/op 0 allocs/op -BenchmarkString_Emoji/clipperhouse/displaywidth-8 3034 ns/op 238.61 MB/s 0 B/op 0 allocs/op -BenchmarkString_Emoji/mattn/go-runewidth-8 4797 ns/op 150.94 MB/s 0 B/op 0 allocs/op -BenchmarkString_Emoji/rivo/uniseg-8 6612 ns/op 109.50 MB/s 0 B/op 0 allocs/op +BenchmarkString_Emoji/clipperhouse/displaywidth-8 3017 ns/op 240.01 MB/s 0 B/op 0 allocs/op +BenchmarkString_Emoji/mattn/go-runewidth-8 4745 ns/op 152.58 MB/s 0 B/op 0 allocs/op +BenchmarkString_Emoji/rivo/uniseg-8 6745 ns/op 107.34 MB/s 0 B/op 0 allocs/op -BenchmarkRune_Mixed/clipperhouse/displaywidth-8 3343 ns/op 504.67 MB/s 0 B/op 0 allocs/op -BenchmarkRune_Mixed/mattn/go-runewidth-8 5414 ns/op 311.62 MB/s 0 B/op 0 allocs/op +BenchmarkRune_Mixed/clipperhouse/displaywidth-8 3381 ns/op 498.90 MB/s 0 B/op 0 allocs/op +BenchmarkRune_Mixed/mattn/go-runewidth-8 5383 ns/op 313.41 MB/s 0 B/op 0 allocs/op -BenchmarkRune_EastAsian/clipperhouse/displaywidth-8 3393 ns/op 497.17 MB/s 0 B/op 0 allocs/op -BenchmarkRune_EastAsian/mattn/go-runewidth-8 15312 ns/op 110.17 MB/s 0 B/op 0 allocs/op +BenchmarkRune_EastAsian/clipperhouse/displaywidth-8 3395 ns/op 496.96 MB/s 0 B/op 0 allocs/op +BenchmarkRune_EastAsian/mattn/go-runewidth-8 15645 ns/op 107.83 MB/s 0 B/op 0 allocs/op -BenchmarkRune_ASCII/clipperhouse/displaywidth-8 256.9 ns/op 498.32 MB/s 0 B/op 0 allocs/op -BenchmarkRune_ASCII/mattn/go-runewidth-8 265.7 ns/op 481.75 MB/s 0 B/op 0 allocs/op +BenchmarkRune_ASCII/clipperhouse/displaywidth-8 257.8 ns/op 496.57 MB/s 0 B/op 0 allocs/op +BenchmarkRune_ASCII/mattn/go-runewidth-8 267.3 ns/op 478.89 MB/s 0 B/op 0 allocs/op -BenchmarkRune_Emoji/clipperhouse/displaywidth-8 1336 ns/op 541.96 MB/s 0 B/op 0 allocs/op -BenchmarkRune_Emoji/mattn/go-runewidth-8 2304 ns/op 314.23 MB/s 0 B/op 0 allocs/op +BenchmarkRune_Emoji/clipperhouse/displaywidth-8 1338 ns/op 541.24 MB/s 0 B/op 0 allocs/op +BenchmarkRune_Emoji/mattn/go-runewidth-8 2287 ns/op 316.58 MB/s 0 B/op 0 allocs/op + +BenchmarkTruncateWithTail/clipperhouse/displaywidth-8 3689 ns/op 47.98 MB/s 192 B/op 14 allocs/op +BenchmarkTruncateWithTail/mattn/go-runewidth-8 8069 ns/op 21.93 MB/s 192 B/op 14 allocs/op + +BenchmarkTruncateWithoutTail/clipperhouse/displaywidth-8 3457 ns/op 66.24 MB/s 0 B/op 0 allocs/op +BenchmarkTruncateWithoutTail/mattn/go-runewidth-8 10441 ns/op 21.93 MB/s 0 B/op 0 allocs/op ``` diff --git a/comparison/benchmark_test.go b/comparison/benchmark_test.go index 7e3e062..0c697e2 100644 --- a/comparison/benchmark_test.go +++ b/comparison/benchmark_test.go @@ -474,3 +474,99 @@ func BenchmarkRune_Emoji(b *testing.B) { } }) } + +func BenchmarkTruncateWithTail(b *testing.B) { + testStrings := []string{ + "hello world", + "This is a very long string that will definitely be truncated", + "Hello δΈ–η•Œ! πŸ˜€", + "πŸ‘¨β€πŸ’» working on πŸš€", + "中文字符串桋试", + "πŸ˜€πŸ˜πŸ˜‚πŸ€£πŸ˜ƒπŸ˜„πŸ˜…πŸ˜†πŸ˜‰πŸ˜Š", + } + + n := int64(0) + for _, s := range testStrings { + n += int64(len(s)) + } + + maxWidths := []int{5, 10, 20, 30} + tail := "..." + + b.Run("clipperhouse/displaywidth", func(b *testing.B) { + options := displaywidth.Options{} + b.SetBytes(n) + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + for _, s := range testStrings { + for _, w := range maxWidths { + _ = options.TruncateString(s, w, tail) + } + } + } + }) + + b.Run("mattn/go-runewidth", func(b *testing.B) { + b.SetBytes(n) + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + for _, s := range testStrings { + for _, w := range maxWidths { + _ = runewidth.Truncate(s, w, tail) + } + } + } + }) +} + +func BenchmarkTruncateWithoutTail(b *testing.B) { + testStrings := []string{ + "hello world", + "This is a very long string that will definitely be truncated", + "Hello δΈ–η•Œ! πŸ˜€", + "πŸ‘¨β€πŸ’» working on πŸš€", + "a very long string that will definitely be truncated", + "中文字符串桋试", + "πŸ˜€πŸ˜πŸ˜‚πŸ€£πŸ˜ƒπŸ˜„πŸ˜…πŸ˜†πŸ˜‰πŸ˜Š", + } + + n := int64(0) + for _, s := range testStrings { + n += int64(len(s)) + } + + maxWidths := []int{5, 10, 20, 30} + + b.Run("clipperhouse/displaywidth", func(b *testing.B) { + options := displaywidth.Options{} + b.SetBytes(n) + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + for _, s := range testStrings { + for _, w := range maxWidths { + _ = options.TruncateString(s, w, "") + } + } + } + }) + + b.Run("mattn/go-runewidth", func(b *testing.B) { + b.SetBytes(n) + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + for _, s := range testStrings { + for _, w := range maxWidths { + _ = runewidth.Truncate(s, w, "") + } + } + } + }) +} diff --git a/fuzz_test.go b/fuzz_test.go index 650c608..7a510b2 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -2,14 +2,15 @@ package displaywidth import ( "bytes" + "strings" "testing" "unicode/utf8" "github.com/clipperhouse/displaywidth/testdata" ) -// FuzzBytes fuzzes the Bytes function with valid and invalid UTF-8. -func FuzzBytes(f *testing.F) { +// FuzzBytesAndString fuzzes the Bytes function with valid and invalid UTF-8. +func FuzzBytesAndString(f *testing.F) { if testing.Short() { f.Skip("skipping fuzz test in short mode") } @@ -114,12 +115,9 @@ func FuzzBytes(f *testing.F) { t.Errorf("Bytes() with options %+v returned non-zero width %d for empty input", option, wb) } - // Consistency check with String() for valid UTF-8 - if utf8.Valid(text) { - ws := option.String(string(text)) - if wb != ws { - t.Errorf("Bytes() returned %d but String() returned %d with options %+v for %q", wb, ws, option, text) - } + ws := option.String(string(text)) + if wb != ws { + t.Errorf("Bytes() returned %d but String() returned %d with options %+v for %q", wb, ws, option, text) } } }) @@ -222,3 +220,110 @@ func FuzzRune(f *testing.F) { } }) } + +func FuzzTruncateStringAndBytes(f *testing.F) { + if testing.Short() { + f.Skip("skipping fuzz test in short mode") + } + + // Seed with multi-lingual text (paragraph-sized chunks) + file, err := testdata.Sample() + if err != nil { + f.Fatal(err) + } + fs := string(file) + chunks := strings.Split(fs, "\n") + for _, chunk := range chunks { + f.Add(chunk) + } + + // Seed with invalid UTF-8 + invalid, err := testdata.InvalidUTF8() + if err != nil { + f.Fatal(err) + } + fs = string(invalid) + chunks = strings.Split(fs, "\n") + for _, chunk := range chunks { + f.Add(chunk) + } + + // Seed with test cases + testCases, err := testdata.TestCases() + if err != nil { + f.Fatal(err) + } + fs = string(testCases) + chunks = strings.Split(fs, "\n") + for _, chunk := range chunks { + f.Add(chunk) + } + + // Seed with random bytes + for i := 0; i < 10; i++ { + b, err := testdata.RandomBytes() + if err != nil { + f.Fatal(err) + } + f.Add(string(b)) + } + + // Seed with edge cases + f.Add("") // empty + f.Add("a") // single ASCII + f.Add("\t\n\r") // whitespace + f.Add("🌍") // emoji + f.Add("\u0301") // combining mark + f.Add("\xff\xfe\xfd") // invalid UTF-8 + + f.Fuzz(func(t *testing.T, text string) { + // Test with default options + ts := TruncateString(text, 10, "...") + + // Invariant: truncated string should be less than or equal to maxWidth + if String(ts) > 10 { + t.Errorf("TruncateString() returned string longer than maxWidth for %q: %q", text, ts) + } + + // Invariant: truncated string should be less than or equal to maxWidth + if len(ts) > len(text) { + t.Errorf("TruncateString() returned string longer than original for %q: %q", text, ts) + } + + tb := TruncateBytes([]byte(text), 10, []byte("...")) + + // Invariant: truncated bytes should be less than or equal to maxWidth + if Bytes(tb) > 10 { + t.Errorf("TruncateBytes() returned bytes longer than maxWidth for %q: %q", text, tb) + } + + // Invariant: truncated bytes should be less than or equal to original + if len(tb) > len(text) { + t.Errorf("TruncateBytes() returned bytes longer than original for %q: %q", text, tb) + } + + if !bytes.Equal(tb, []byte(ts)) { + t.Errorf("TruncateBytes() returned bytes different from TruncateString() for %q: %q != %q", text, tb, ts) + } + + // Test with different options + options := []Options{ + {EastAsianWidth: false}, // default + {EastAsianWidth: true}, + } + + for _, option := range options { + ts := option.TruncateString(text, 10, "...") + + // Invariant: truncated string should be less than or equal to maxWidth + if option.String(ts) > 10 { + t.Errorf("TruncateString() returned string longer than maxWidth for %q: %q", text, ts) + } + + tb := option.TruncateBytes([]byte(text), 10, []byte("...")) + if !bytes.Equal(tb, []byte(ts)) { + t.Errorf("TruncateBytes() returned bytes different from TruncateString() for %q: %q != %q", text, tb, ts) + } + } + }) +} diff --git a/width.go b/width.go index a96ab0b..d3fc741 100644 --- a/width.go +++ b/width.go @@ -107,6 +107,72 @@ func (options Options) Rune(r rune) int { const _Default property = 0 +// TruncateString truncates a string to the given maxWidth, and appends the +// given tail if the string is truncated. +// +// It ensures the total width, including the width of the tail, is less than or +// equal to maxWidth. +func (options Options) TruncateString(s string, maxWidth int, tail string) string { + maxWidthWithoutTail := maxWidth - options.String(tail) + + var pos, total int + g := graphemes.FromString(s) + for g.Next() { + gw := graphemeWidth(g.Value(), options) + if total <= maxWidthWithoutTail { + pos = g.Start() + } + total += gw + if total > maxWidth { + return s[:pos] + tail + } + } + // No truncation + return s +} + +// TruncateString truncates a string to the given maxWidth, and appends the +// given tail if the string is truncated. +// +// It ensures the total width, including the width of the tail, is less than or +// equal to maxWidth. +func TruncateString(s string, maxWidth int, tail string) string { + return DefaultOptions.TruncateString(s, maxWidth, tail) +} + +// TruncateBytes truncates a []byte to the given maxWidth, and appends the +// given tail if the []byte is truncated. +// +// It ensures the total width, including the width of the tail, is less than or +// equal to maxWidth. +func (options Options) TruncateBytes(s []byte, maxWidth int, tail []byte) []byte { + maxWidthWithoutTail := maxWidth - options.Bytes(tail) + + var pos, total int + g := graphemes.FromBytes(s) + for g.Next() { + gw := graphemeWidth(g.Value(), options) + if total <= maxWidthWithoutTail { + pos = g.Start() + } + total += gw + if total > maxWidth { + return append(s[:pos], tail...) + } + } + // No truncation + return s +} + +// TruncateBytes truncates a []byte to the given maxWidth, and appends the +// given tail if the []byte is truncated. +// +// It ensures the total width, including the width of the tail, is less than or +// equal to maxWidth. +func TruncateBytes(s []byte, maxWidth int, tail []byte) []byte { + return DefaultOptions.TruncateBytes(s, maxWidth, tail) +} + // graphemeWidth returns the display width of a grapheme cluster. // The passed string must be a single grapheme cluster. func graphemeWidth[T stringish.Interface](s T, options Options) int { diff --git a/width_test.go b/width_test.go index 80c184a..c03aeb8 100644 --- a/width_test.go +++ b/width_test.go @@ -1,6 +1,7 @@ package displaywidth import ( + "bytes" "testing" ) @@ -847,3 +848,143 @@ func TestAsciiWidth(t *testing.T) { }) } } + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + input string + maxWidth int + tail string + options Options + expected string + }{ + // Empty string cases + {"empty string", "", 0, "", defaultOptions, ""}, + {"empty string with tail", "", 5, "...", defaultOptions, ""}, + {"empty string large maxWidth", "", 100, "...", defaultOptions, ""}, + + // No truncation needed + {"fits exactly", "hello", 5, "...", defaultOptions, "hello"}, + {"fits with room", "hi", 10, "...", defaultOptions, "hi"}, + {"single char fits", "a", 1, "...", defaultOptions, "a"}, + + // Basic truncation - ASCII + {"truncate ASCII", "hello world", 5, "...", defaultOptions, "he..."}, + {"truncate ASCII at start", "hello", 0, "...", defaultOptions, "..."}, + {"truncate ASCII single char", "hello", 1, "...", defaultOptions, "..."}, + {"truncate ASCII with empty tail", "hello world", 5, "", defaultOptions, "hello"}, + + // Truncation with wide characters (CJK) + {"CJK fits", "δΈ­", 2, "...", defaultOptions, "δΈ­"}, + {"CJK truncate", "δΈ­", 1, "...", defaultOptions, "..."}, + {"CJK with ASCII", "helloδΈ­", 5, "...", defaultOptions, "he..."}, + {"CJK with ASCII fits", "helloδΈ­", 7, "...", defaultOptions, "helloδΈ­"}, + {"CJK with ASCII partial", "helloδΈ­", 6, "...", defaultOptions, "hel..."}, + {"multiple CJK", "δΈ­ζ–‡", 2, "...", defaultOptions, "..."}, + {"multiple CJK fits", "δΈ­ζ–‡", 4, "...", defaultOptions, "δΈ­ζ–‡"}, + + // Truncation with emoji + {"emoji fits", "πŸ˜€", 2, "...", defaultOptions, "πŸ˜€"}, + {"emoji truncate", "πŸ˜€", 1, "...", defaultOptions, "..."}, + {"emoji with ASCII", "helloπŸ˜€", 5, "...", defaultOptions, "he..."}, + {"emoji with ASCII fits", "helloπŸ˜€", 7, "...", defaultOptions, "helloπŸ˜€"}, + {"multiple emoji", "πŸ˜€πŸ˜", 2, "...", defaultOptions, "..."}, + {"multiple emoji fits", "πŸ˜€πŸ˜", 4, "...", defaultOptions, "πŸ˜€πŸ˜"}, + + // Truncation with control characters (zero width) + // Control characters have width 0 but are preserved in the string structure + {"with newline", "hello\nworld", 5, "...", defaultOptions, "he..."}, + {"with tab", "hello\tworld", 5, "...", defaultOptions, "he..."}, + {"newline at start", "\nhello", 5, "...", defaultOptions, "\nhello"}, + {"multiple newlines", "a\n\nb", 1, "...", defaultOptions, "..."}, + + // Mixed content + {"ASCII CJK emoji", "hiδΈ­πŸ˜€", 2, "...", defaultOptions, "..."}, + {"ASCII CJK emoji fits", "hiδΈ­πŸ˜€", 6, "...", defaultOptions, "hiδΈ­πŸ˜€"}, + {"ASCII CJK emoji partial", "hiδΈ­πŸ˜€", 4, "...", defaultOptions, "h..."}, + {"complex mixed", "Go πŸ‡ΊπŸ‡ΈπŸš€", 3, "...", defaultOptions, "..."}, + {"complex mixed fits", "Go πŸ‡ΊπŸ‡ΈπŸš€", 7, "...", defaultOptions, "Go πŸ‡ΊπŸ‡ΈπŸš€"}, + + // East Asian Width option + {"ambiguous EAW fits", "β˜…", 2, "...", eawOptions, "β˜…"}, + {"ambiguous EAW truncate", "β˜…", 1, "...", eawOptions, "..."}, + {"ambiguous default fits", "β˜…", 1, "...", defaultOptions, "β˜…"}, + {"ambiguous mixed", "aβ˜…b", 2, "...", eawOptions, "..."}, + {"ambiguous mixed default", "aβ˜…b", 2, "...", defaultOptions, "..."}, + + // Edge cases + {"zero maxWidth", "hello", 0, "...", defaultOptions, "..."}, + {"very long string", "a very long string that will definitely be truncated", 10, "...", defaultOptions, "a very ..."}, + + // Tail variations + {"custom tail", "hello world", 5, "…", defaultOptions, "hell…"}, + {"long tail", "hello", 3, ">>>", defaultOptions, ">>>"}, + {"tail with wide char", "hello", 3, "δΈ­", defaultOptions, "hδΈ­"}, + {"tail with emoji", "hello", 3, "πŸ˜€", defaultOptions, "hπŸ˜€"}, + + // Grapheme boundary tests (ensuring truncation happens at grapheme boundaries) + {"keycap sequence", "1️⃣2️⃣", 2, "...", defaultOptions, "..."}, + {"flag sequence", "πŸ‡ΊπŸ‡ΈπŸ‡―πŸ‡΅", 2, "...", defaultOptions, "..."}, + {"ZWJ sequence", "πŸ‘¨β€πŸ‘©β€πŸ‘§", 2, "...", defaultOptions, "πŸ‘¨β€πŸ‘©β€πŸ‘§"}, + {"ZWJ sequence truncate", "πŸ‘¨β€πŸ‘©β€πŸ‘§πŸ‘¨β€πŸ‘©β€πŸ‘§", 2, "...", defaultOptions, "..."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + { + got := tt.options.TruncateString(tt.input, tt.maxWidth, tt.tail) + if got != tt.expected { + t.Errorf("TruncateString(%q, %d, %q) with options %v = %q, want %q", + tt.input, tt.maxWidth, tt.tail, tt.options, got, tt.expected) + // Show width information for debugging + inputWidth := tt.options.String(tt.input) + gotWidth := tt.options.String(got) + t.Logf(" Input width: %d, Got width: %d, MaxWidth: %d", inputWidth, gotWidth, tt.maxWidth) + } + + if len(got) >= len(tt.tail) && tt.tail != "" { + truncatedPart := got[:len(got)-len(tt.tail)] + truncatedWidth := tt.options.String(truncatedPart) + if truncatedWidth > tt.maxWidth { + t.Errorf("Truncated part width (%d) exceeds maxWidth (%d)", truncatedWidth, tt.maxWidth) + } + } else if tt.tail == "" { + // If no tail, the result itself should fit within maxWidth + gotWidth := tt.options.String(got) + if gotWidth > tt.maxWidth { + t.Errorf("Result width (%d) exceeds maxWidth (%d) when tail is empty", gotWidth, tt.maxWidth) + } + } + + } + { + input := []byte(tt.input) + tail := []byte(tt.tail) + expected := []byte(tt.expected) + got := tt.options.TruncateBytes(input, tt.maxWidth, tail) + if !bytes.Equal(got, expected) { + t.Errorf("TruncateBytes(%q, %d, %q) with options %v = %q, want %q", + input, tt.maxWidth, tail, tt.options, got, expected) + // Show width information for debugging + inputWidth := tt.options.Bytes(input) + gotWidth := tt.options.Bytes(got) + t.Logf(" Input width: %d, Got width: %d, MaxWidth: %d", inputWidth, gotWidth, tt.maxWidth) + } + + if len(got) >= len(tt.tail) && tt.tail != "" { + truncatedPart := got[:len(got)-len(tt.tail)] + truncatedWidth := tt.options.Bytes(truncatedPart) + if truncatedWidth > tt.maxWidth { + t.Errorf("Truncated part width (%d) exceeds maxWidth (%d)", truncatedWidth, tt.maxWidth) + } + } else if tt.tail == "" { + // If no tail, the result itself should fit within maxWidth + gotWidth := tt.options.Bytes(got) + if gotWidth > tt.maxWidth { + t.Errorf("Result width (%d) exceeds maxWidth (%d) when tail is empty", gotWidth, tt.maxWidth) + } + } + } + }) + } +}