diff --git a/internal/ui/colsort.go b/internal/ui/colsort.go index 8cc683b..4535212 100644 --- a/internal/ui/colsort.go +++ b/internal/ui/colsort.go @@ -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) @@ -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) { diff --git a/internal/ui/colsort_test.go b/internal/ui/colsort_test.go index 85a2f3f..b15d794 100644 --- a/internal/ui/colsort_test.go +++ b/internal/ui/colsort_test.go @@ -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 { @@ -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) + } + } +}