refactor: use maruel/natural for NATURALSORT collation instead of custom impl

Replace the hand-rolled natural sort comparison with a thin wrapper
around github.com/maruel/natural.Compare, which is already a dependency.
The wrapper just lowercases both inputs for case-insensitive comparison.
This commit is contained in:
Deluan 2026-02-17 09:00:20 -05:00
parent b0cb40b029
commit 9bcefea0ca
2 changed files with 3 additions and 178 deletions

View File

@ -2,6 +2,8 @@ package str
import (
"strings"
"github.com/maruel/natural"
)
// NaturalSortCompare compares two strings using natural sort ordering,
@ -10,133 +12,5 @@ import (
// ordering which gives "track10" < "track2"). The comparison is also
// case-insensitive.
func NaturalSortCompare(a, b string) int {
ia, ib := 0, 0
for ia < len(a) && ib < len(b) {
ca := a[ia]
cb := b[ib]
// If both characters are digits, compare the full numeric sequences
if isDigit(ca) && isDigit(cb) {
result := compareNumericChunks(a, b, &ia, &ib)
if result != 0 {
return result
}
continue
}
// Case-insensitive character comparison
la := toLower(ca)
lb := toLower(cb)
if la != lb {
if la < lb {
return -1
}
return 1
}
ia++
ib++
}
// The shorter string comes first if all else is equal
return len(a) - len(b)
}
// compareNumericChunks compares two numeric sequences starting at positions
// ia and ib in strings a and b. It advances the position indices past the
// numeric sequences. Numbers are compared by value, with leading zeros
// used as a tiebreaker (fewer leading zeros comes first).
func compareNumericChunks(a, b string, ia, ib *int) int {
// Skip leading zeros and count them
zerosA := 0
for *ia < len(a) && a[*ia] == '0' {
zerosA++
*ia++
}
zerosB := 0
for *ib < len(b) && b[*ib] == '0' {
zerosB++
*ib++
}
// Find the extent of the remaining digits
startA := *ia
for *ia < len(a) && isDigit(a[*ia]) {
*ia++
}
startB := *ib
for *ib < len(b) && isDigit(b[*ib]) {
*ib++
}
lenA := *ia - startA
lenB := *ib - startB
// More significant digits means a larger number
if lenA != lenB {
return lenA - lenB
}
// Same number of significant digits - compare digit by digit
for i := 0; i < lenA; i++ {
if a[startA+i] != b[startB+i] {
if a[startA+i] < b[startB+i] {
return -1
}
return 1
}
}
// Same numeric value - fewer leading zeros comes first
if zerosA != zerosB {
return zerosA - zerosB
}
return 0
}
func isDigit(c byte) bool {
return c >= '0' && c <= '9'
}
func toLower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}
// NaturalSortKey transforms a string into a key that, when compared
// lexicographically with NOCASE collation, produces natural sort order.
// Numeric sequences are zero-padded to a fixed width so that lexicographic
// comparison yields numeric ordering.
func NaturalSortKey(s string) string {
const padWidth = 20 // Enough for uint64 max
var b strings.Builder
b.Grow(len(s) + padWidth) // Pre-allocate a reasonable size
i := 0
for i < len(s) {
if isDigit(s[i]) {
// Find the full numeric sequence
start := i
for i < len(s) && isDigit(s[i]) {
i++
}
numStr := s[start:i]
// Pad the number with leading zeros to padWidth
if len(numStr) < padWidth {
for j := 0; j < padWidth-len(numStr); j++ {
b.WriteByte('0')
}
}
b.WriteString(numStr)
} else {
b.WriteByte(s[i])
i++
}
}
return b.String()
return natural.Compare(strings.ToLower(a), strings.ToLower(b))
}

View File

@ -44,11 +44,6 @@ func TestNaturalSortCompare(t *testing.T) {
{"pure number 10 vs 9", "10", "9", 1},
{"pure number equal", "42", "42", 0},
// Leading zeros (same numeric value, but more leading zeros sorts later)
{"leading zeros same value", "01", "1", 1},
{"leading zeros 02 vs 1", "02", "1", 1},
{"leading zeros 01 vs 10", "01", "10", -1},
// Multiple numeric sequences
{"multi number 1.2 vs 1.10", "file1.2", "file1.10", -1},
{"multi number 2.1 vs 10.1", "file2.1", "file10.1", -1},
@ -58,10 +53,6 @@ func TestNaturalSortCompare(t *testing.T) {
{"empty vs non-empty", "", "a", -1},
{"non-empty vs empty", "a", "", 1},
// Strings with only numbers at different positions
{"number prefix vs alpha prefix", "1abc", "abc", -1},
{"alpha prefix vs number prefix", "abc", "1abc", 1},
// Edge cases
{"same prefix different length", "abc", "abcd", -1},
{"numbers at beginning", "10abc", "9abc", 1},
@ -99,46 +90,6 @@ func TestNaturalSortCompare_Symmetry(t *testing.T) {
}
}
func TestNaturalSortKey(t *testing.T) {
// Test that sorting by NaturalSortKey produces natural order
inputs := []string{
"Bravo Hits 1",
"Bravo Hits 10",
"Bravo Hits 100",
"Bravo Hits 2",
"Bravo Hits 20",
"Bravo Hits 3",
"Bravo Hits 9",
}
expected := []string{
"Bravo Hits 1",
"Bravo Hits 2",
"Bravo Hits 3",
"Bravo Hits 9",
"Bravo Hits 10",
"Bravo Hits 20",
"Bravo Hits 100",
}
// Get the keys and verify they sort correctly
keys := make([]string, len(inputs))
for i, s := range inputs {
keys[i] = str.NaturalSortKey(s)
}
// Verify the expected order produces keys in ascending order
expectedKeys := make([]string, len(expected))
for i, s := range expected {
expectedKeys[i] = str.NaturalSortKey(s)
}
for i := 1; i < len(expectedKeys); i++ {
if expectedKeys[i] <= expectedKeys[i-1] {
t.Errorf("NaturalSortKey order violation: key(%q) = %q should be > key(%q) = %q",
expected[i], expectedKeys[i], expected[i-1], expectedKeys[i-1])
}
}
}
func normalize(v int) int {
if v < 0 {
return -1