diff --git a/truncate.go b/truncate.go new file mode 100644 index 0000000..d0fa2ef --- /dev/null +++ b/truncate.go @@ -0,0 +1,21 @@ +package util + +// TruncateString returns at most max characters from the start of s. It counts +// characters rather than bytes, so a multi-byte character is never cut in half +// and the result is always valid text. A max of zero or less returns an empty +// string, and a string already at or below the limit is returned unchanged. +func TruncateString(s string, max int) string { + if max <= 0 { + return "" + } + + count := 0 + for offset := range s { + if count == max { + return s[:offset] + } + count++ + } + + return s +} diff --git a/truncate_test.go b/truncate_test.go new file mode 100644 index 0000000..a46325f --- /dev/null +++ b/truncate_test.go @@ -0,0 +1,41 @@ +package util + +import "testing" + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + input string + max int + expected string + }{ + {"shorter than the limit", "abc", 5, "abc"}, + {"exactly at the limit", "abc", 3, "abc"}, + {"longer than the limit", "abcdef", 3, "abc"}, + {"max of one", "abc", 1, "a"}, + {"max of zero", "abc", 0, ""}, + {"negative max", "abc", -1, ""}, + {"empty string", "", 3, ""}, + {"multi-byte characters kept whole", "héllo", 3, "hél"}, + {"multi-byte characters below the limit", "héllo", 99, "héllo"}, + {"characters outside the basic plane", "a😀b", 2, "a😀"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := TruncateString(test.input, test.max) + if got != test.expected { + t.Errorf("expected %q got %q", test.expected, got) + } + }) + } +} + +func TestTruncateStringCountsCharactersNotBytes(t *testing.T) { + // Each of these characters is two bytes long, so a byte-based cut at + // three would leave half a character behind. + got := TruncateString("äöü", 3) + if got != "äöü" { + t.Errorf("expected %q got %q", "äöü", got) + } +}