Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 93 additions & 9 deletions normalize_space.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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()
}
9 changes: 9 additions & 0 deletions normalize_space_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
104 changes: 99 additions & 5 deletions string_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions string_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package str

import (
"strings"
"testing"
"unicode/utf8"
)
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion trim.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading