diff --git a/normalize_space.go b/normalize_space.go index 6174447..352cbc9 100644 --- a/normalize_space.go +++ b/normalize_space.go @@ -1,6 +1,10 @@ package str -import "unicode" +import ( + "strings" + "unicode" + "unicode/utf8" +) // NormalizeSpace removes surrounding whitespace and collapses internal whitespace to single spaces. // Similar: Trim. @@ -12,22 +16,102 @@ import "unicode" // println(v) // // #string go forj func (s String) NormalizeSpace() String { - var out []rune - seenWord := false + trimmed := strings.TrimSpace(s.s) + if trimmed == "" { + return String{s: trimmed} + } + + isASCII, needsRewrite := normalizeSpaceState(trimmed) + if !needsRewrite { + if len(trimmed) == len(s.s) { + return s + } + // Clone prevents a small trimmed result from retaining the full source buffer. + return String{s: strings.Clone(trimmed)} + } + if isASCII { + return String{s: normalizeSpaceASCII(trimmed)} + } + return String{s: normalizeSpaceUnicode(trimmed)} +} + +// normalizeSpaceState reports whether value is ASCII and whether its whitespace or encoding needs normalization. +func normalizeSpaceState(value string) (bool, bool) { + isASCII := true + previousSpace := false + needsRewrite := false + for i := 0; i < len(value); { + c := value[i] + if c >= utf8.RuneSelf { + isASCII = false + r, size := utf8.DecodeRuneInString(value[i:]) + if unicode.IsSpace(r) { + return false, true + } + // Rune iteration historically replaces each invalid byte with utf8.RuneError. + if r == utf8.RuneError && size == 1 { + return false, true + } + previousSpace = false + i += size + continue + } + if isNormalizeSpaceASCIIByte(c) { + if c != ' ' || previousSpace { + needsRewrite = true + } + previousSpace = true + i++ + continue + } + previousSpace = false + i++ + } + return isASCII, needsRewrite +} + +// normalizeSpaceASCII collapses whitespace in an ASCII value that is already trimmed. +func normalizeSpaceASCII(value string) string { + var out strings.Builder + out.Grow(len(value)) + pendingSpace := false + for i := 0; i < len(value); i++ { + c := value[i] + if isNormalizeSpaceASCIIByte(c) { + pendingSpace = true + continue + } + if pendingSpace { + out.WriteByte(' ') + pendingSpace = false + } + out.WriteByte(c) + } + return out.String() +} + +// isNormalizeSpaceASCIIByte reports whether c is whitespace according to Unicode's ASCII subset. +func isNormalizeSpaceASCIIByte(c byte) bool { + return c == ' ' || '\t' <= c && c <= '\r' +} + +// normalizeSpaceUnicode collapses whitespace while preserving Unicode text and historical invalid-UTF-8 normalization. +func normalizeSpaceUnicode(value string) string { + var out strings.Builder + out.Grow(len(value)) pendingSpace := false - for _, r := range s.s { + for _, r := range value { if unicode.IsSpace(r) { - pendingSpace = seenWord + pendingSpace = true continue } if pendingSpace { - out = append(out, ' ') + out.WriteByte(' ') pendingSpace = false } - out = append(out, r) - seenWord = true + out.WriteRune(r) } - return String{s: string(out)} + return out.String() } diff --git a/normalize_space_test.go b/normalize_space_test.go index 9d5c8d5..13b76ab 100644 --- a/normalize_space_test.go +++ b/normalize_space_test.go @@ -15,4 +15,13 @@ func TestNormalizeSpace(t *testing.T) { if got := Of("").NormalizeSpace().String(); got != "" { t.Fatalf("NormalizeSpace empty = %q", got) } + if got := Of("GoForj builds practical Go applications").NormalizeSpace().String(); got != "GoForj builds practical Go applications" { + t.Fatalf("NormalizeSpace clean = %q", got) + } + if got := Of("\u2003GoForj\u00a0\tbuilds\u2002Go\u2003").NormalizeSpace().String(); got != "GoForj builds Go" { + t.Fatalf("NormalizeSpace Unicode whitespace = %q", got) + } + if got := Of("Go\xff Forj").NormalizeSpace().String(); got != "Go\uFFFD Forj" { + t.Fatalf("NormalizeSpace invalid UTF-8 = %q", got) + } } diff --git a/string_benchmark_test.go b/string_benchmark_test.go index 49a2cac..d6d8866 100644 --- a/string_benchmark_test.go +++ b/string_benchmark_test.go @@ -1,8 +1,12 @@ package str -import "testing" +import ( + "strings" + "testing" +) var benchmarkStringResult String +var benchmarkRawStringResult string // BenchmarkAppend measures a common multi-part fluent composition. func BenchmarkAppend(b *testing.B) { @@ -11,14 +15,104 @@ func BenchmarkAppend(b *testing.B) { } } -// BenchmarkNormalizeSpace measures Unicode-aware whitespace normalization. +// BenchmarkTrim measures leading and trailing whitespace removal across representative inputs. +func BenchmarkTrim(b *testing.B) { + benchmarks := []struct { + name string + value string + }{ + {name: "ASCII", value: " GoForj builds practical Go applications "}, + {name: "Unicode", value: "\u2003GoForj builds practical Go applications\u00a0"}, + {name: "Clean", value: "GoForj builds practical Go applications"}, + } + + for _, benchmark := range benchmarks { + value := Of(benchmark.value) + b.Run(benchmark.name, func(b *testing.B) { + for b.Loop() { + benchmarkStringResult = value.Trim() + } + }) + } +} + +// BenchmarkNormalizeSpace measures Unicode-aware whitespace normalization across representative inputs. func BenchmarkNormalizeSpace(b *testing.B) { - value := Of(" GoForj\tbuilds\n practical\u2003Go applications ") - for b.Loop() { - benchmarkStringResult = value.NormalizeSpace() + benchmarks := []struct { + name string + value string + }{ + {name: "ASCII", value: " GoForj\tbuilds\n practical Go applications "}, + {name: "Unicode", value: "\u2003GoForj\tbuilds\n practical\u2003Go applications\u00a0"}, + {name: "Clean", value: "GoForj builds practical Go applications"}, + {name: "Trimmed", value: " GoForj builds practical Go applications "}, + {name: "Whitespace", value: " \t\n\u2003\u00a0 "}, + } + + for _, benchmark := range benchmarks { + value := Of(benchmark.value) + b.Run(benchmark.name, func(b *testing.B) { + for b.Loop() { + benchmarkStringResult = value.NormalizeSpace() + } + }) } } +// BenchmarkNormalizeSpaceComparison compares fluent normalization with the equivalent standard-library composition. +func BenchmarkNormalizeSpaceComparison(b *testing.B) { + const value = " SELECT users.id, users.email\nFROM users\tWHERE users.status = ? " + + b.Run("StandardLibrary", func(b *testing.B) { + for b.Loop() { + benchmarkRawStringResult = strings.Join(strings.Fields(value), " ") + } + }) + b.Run("Fluent", func(b *testing.B) { + for b.Loop() { + benchmarkRawStringResult = Of(value).NormalizeSpace().String() + } + }) +} + +// BenchmarkNormalizationPipeline compares fluent composition with the equivalent standard-library pipeline. +func BenchmarkNormalizationPipeline(b *testing.B) { + const value = " AUTH_OAuth_Provider-Name.Test " + + b.Run("StandardLibrary", func(b *testing.B) { + for b.Loop() { + benchmarkRawStringResult = strings.ToLower(strings.TrimSpace(value)) + } + }) + b.Run("Fluent", func(b *testing.B) { + for b.Loop() { + benchmarkRawStringResult = Of(value).Trim().ToLower().String() + } + }) +} + +// BenchmarkReplaceAllPipeline compares ordered fluent replacements with equivalent standard-library calls. +func BenchmarkReplaceAllPipeline(b *testing.B) { + const value = "archive-logs cold.storage" + + b.Run("StandardLibrary", func(b *testing.B) { + for b.Loop() { + result := strings.ReplaceAll(value, "-", "_") + result = strings.ReplaceAll(result, " ", "_") + benchmarkRawStringResult = strings.ReplaceAll(result, ".", "_") + } + }) + b.Run("Fluent", func(b *testing.B) { + for b.Loop() { + benchmarkRawStringResult = Of(value). + ReplaceAll("-", "_"). + ReplaceAll(" ", "_"). + ReplaceAll(".", "_"). + String() + } + }) +} + // BenchmarkReplaceFold measures repeated Unicode simple-fold replacement. func BenchmarkReplaceFold(b *testing.B) { value := Of("Go Σ go ς GO σ gopher") diff --git a/string_fuzz_test.go b/string_fuzz_test.go index 7c758fc..b2b30c1 100644 --- a/string_fuzz_test.go +++ b/string_fuzz_test.go @@ -1,6 +1,7 @@ package str import ( + "strings" "testing" "unicode/utf8" ) @@ -34,6 +35,9 @@ func FuzzStringInvariants(f *testing.F) { } normalized := wrapped.NormalizeSpace() + if want := strings.Join(strings.Fields(value), " "); normalized.String() != want { + t.Fatalf("NormalizeSpace = %q, want %q", normalized.String(), want) + } if got := normalized.NormalizeSpace().String(); got != normalized.String() { t.Fatalf("NormalizeSpace is not idempotent: %q then %q", normalized.String(), got) } diff --git a/trim.go b/trim.go index 011d58a..7aa920d 100644 --- a/trim.go +++ b/trim.go @@ -15,7 +15,7 @@ import ( // println(v) // // #string GoForj func (s String) Trim() String { - return String{s: strings.TrimFunc(s.s, unicode.IsSpace)} + return String{s: strings.TrimSpace(s.s)} } // TrimChars removes leading and trailing runes contained in cutset.