Skip to content
Open
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
61 changes: 58 additions & 3 deletions internal/ui/colsort.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import (
// cellLess orders two table cells. Duration-shaped values ("2m59s", "5d")
// compare by elapsed time, numeric-looking values (CPU "245m", MEM "1234Mi",
// percentages, restart counts, ready ratios) compare numerically, and
// everything else compares case-insensitively. Unparseable/empty cells sort
// after numeric ones.
// everything else compares naturally: case-insensitive, with embedded digit
// runs compared by value so "node-2" sorts before "node-10". Unparseable/empty
// cells sort after numeric ones.
func cellLess(a, b string) bool {
av, aok := sortVal(a)
bv, bok := sortVal(b)
Expand All @@ -22,7 +23,61 @@ func cellLess(a, b string) bool {
if aok != bok {
return aok
}
return strings.ToLower(a) < strings.ToLower(b)
return naturalLess(a, b)
}

// naturalLess compares two strings chunk by chunk, where a chunk is either a
// run of ASCII digits or a run of anything else. Digit runs compare by numeric
// value (leading zeros ignored), other runs compare case-insensitively. When
// the two strings are equal under those rules, plain byte order breaks the tie
// so the result stays a strict weak ordering.
func naturalLess(a, b string) bool {
i, j := 0, 0
for i < len(a) && j < len(b) {
ad, bd := isDigit(a[i]), isDigit(b[j])
if ad && bd {
ai, bj := i, j
for i < len(a) && isDigit(a[i]) {
i++
}
for j < len(b) && isDigit(b[j]) {
j++
}
an := strings.TrimLeft(a[ai:i], "0")
bn := strings.TrimLeft(b[bj:j], "0")
if len(an) != len(bn) {
return len(an) < len(bn)
}
if an != bn {
return an < bn
}
continue
}
if ad != bd {
// Digits sort before letters and punctuation, so "node1" precedes
// "node-a" regardless of the two runs' byte values.
return ad
}
ac, bc := lower(a[i]), lower(b[j])
if ac != bc {
return ac < bc
}
i++
j++
}
if len(a)-i != len(b)-j {
return len(a)-i < len(b)-j
}
return a < b
}

func isDigit(c byte) bool { return c >= '0' && c <= '9' }

func lower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + 'a' - 'A'
}
return c
}

func sortVal(s string) (float64, bool) {
Expand Down
35 changes: 35 additions & 0 deletions internal/ui/colsort_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ func TestCellLess(t *testing.T) {
{"alpha", "beta", true},
{"Zeta", "alpha", false}, // case-insensitive: z > a
{"Running", "Pending", false},
// Names with embedded numbers sort by value, not lexically.
{"node-2", "node-10", true},
{"node-10", "node-2", false},
{"worker-9.example.com", "worker-10.example.com", true},
{"ip-10-0-1-9.ec2.internal", "ip-10-0-1-23.ec2.internal", true},
{"ip-10-0-2-1.ec2.internal", "ip-10-0-1-99.ec2.internal", false},
{"api-7d9f", "api-7d10f", true},
{"Node-2", "node-10", true}, // case-insensitive across the text runs
}
for _, c := range cases {
if got := cellLess(c.a, c.b); got != c.want {
Expand All @@ -44,3 +52,30 @@ func TestCellLessEmptySortsLast(t *testing.T) {
}
}
}

func TestNaturalLess(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"a2", "a10", true},
{"a10", "a2", false},
{"a02", "a2", true}, // equal by value; byte order breaks the tie
{"a2", "a02", false},
{"a", "a1", true}, // prefix sorts first
{"a1", "a-1", true}, // digits sort before punctuation
{"abc", "abd", true},
{"ABC", "abd", true},
{"same", "same", false},
{"x9y", "x9z", true},
{"x9y", "x10a", true},
}
for _, c := range cases {
if got := naturalLess(c.a, c.b); got != c.want {
t.Errorf("naturalLess(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
if c.a != c.b && naturalLess(c.a, c.b) == naturalLess(c.b, c.a) {
t.Errorf("naturalLess(%q, %q) is not antisymmetric", c.a, c.b)
}
}
}