commit cf4751f200cbdb3823d02647a985555e43706f0f Author: Chandler Swift Date: Sat Aug 30 22:52:35 2025 -0500 file_server: Implement natural sort for browse templates For files with regions of numbers and other characters, it's intuitive to have the numbers sorted by increasing numeric value rather than by ASCII code. (Jeff Atwood has a nice article here: https://blog.codinghorror.com/sorting-for-humans-natural-sort-order/) For example, the listing for a directory containing `foo1`, `foo2`, and `foo10` would sort in that order, rather than putting `foo10` between `foo1` and `foo2`. Closes #7226 diff --git a/modules/caddyhttp/fileserver/browsetplcontext.go b/modules/caddyhttp/fileserver/browsetplcontext.go index b9489c6a..2d7eae1e 100644 --- a/modules/caddyhttp/fileserver/browsetplcontext.go +++ b/modules/caddyhttp/fileserver/browsetplcontext.go @@ -329,7 +329,7 @@ func (l byName) Len() int { return len(l.Items) } func (l byName) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } func (l byName) Less(i, j int) bool { - return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) + return naturalLess(strings.ToLower(l.Items[i].Name), strings.ToLower(l.Items[j].Name)) } func (l byNameDirFirst) Len() int { return len(l.Items) } @@ -338,7 +338,7 @@ func (l byNameDirFirst) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l. func (l byNameDirFirst) Less(i, j int) bool { // sort by name if both are dir or file if l.Items[i].IsDir == l.Items[j].IsDir { - return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) + return naturalLess(strings.ToLower(l.Items[i].Name), strings.ToLower(l.Items[j].Name)) } // sort dir ahead of file return l.Items[i].IsDir diff --git a/modules/caddyhttp/fileserver/natsort.go b/modules/caddyhttp/fileserver/natsort.go new file mode 100644 index 00000000..65f6a161 --- /dev/null +++ b/modules/caddyhttp/fileserver/natsort.go @@ -0,0 +1,65 @@ +package fileserver + +func isDigit(b byte) bool { return '0' <= b && b <= '9' } + +// naturalLess compares two strings using natural ordering. This means that e.g. +// "abc2" < "abc12". +// +// Non-digit sequences and numbers are compared separately. The former are +// compared bytewise, while digits are compared numerically (except that +// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02") +// +// Limitation: only ASCII digits (0-9) are considered. +// +// This implementation is copied from https://github.com/fvbommel/sortorder, +// which is MIT licensed. +func naturalLess(str1, str2 string) bool { + idx1, idx2 := 0, 0 + for idx1 < len(str1) && idx2 < len(str2) { + c1, c2 := str1[idx1], str2[idx2] + dig1, dig2 := isDigit(c1), isDigit(c2) + switch { + case dig1 != dig2: // Digits before other characters. + return dig1 // True if LHS is a digit, false if the RHS is one. + case !dig1: // && !dig2, because dig1 == dig2 + // UTF-8 compares bytewise-lexicographically, no need to decode + // codepoints. + if c1 != c2 { + return c1 < c2 + } + idx1++ + idx2++ + default: // Digits + // Eat zeros. + for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ { + } + for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ { + } + // Eat all digits. + nonZero1, nonZero2 := idx1, idx2 + for ; idx1 < len(str1) && isDigit(str1[idx1]); idx1++ { + } + for ; idx2 < len(str2) && isDigit(str2[idx2]); idx2++ { + } + // If lengths of numbers with non-zero prefix differ, the shorter + // one is less. + if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 { + return len1 < len2 + } + // If they're equally long, string comparison is correct. + if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 { + return nr1 < nr2 + } + // Otherwise, the one with less zeros is less. + // Because everything up to the number is equal, comparing the index + // after the zeros is sufficient. + if nonZero1 != nonZero2 { + return nonZero1 < nonZero2 + } + } + // They're identical so far, so continue comparing. + } + // So far they are identical. At least one is ended. If the other continues, + // it sorts last. + return len(str1) < len(str2) +} diff --git a/modules/caddyhttp/fileserver/natsort_test.go b/modules/caddyhttp/fileserver/natsort_test.go new file mode 100644 index 00000000..321f1197 --- /dev/null +++ b/modules/caddyhttp/fileserver/natsort_test.go @@ -0,0 +1,63 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileserver + +import ( + "context" + "io/fs" + "net/http/httptest" + "os" + "testing" + + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +// TestNatSort confirms that, although an ASCIIbetical sort would order foo2.txt +// after foo10.txt, Caddy will return them in a "natural" human-intuitive order. +func TestNatSort(t *testing.T) { + fsrv := &FileServer{Browse: &Browse{}} + + base := "./testdata" + dirName := "natsort" + + fsys := os.DirFS(base) + f, err := fsys.Open(dirName) + if err != nil { + t.Fatalf("opening testdata dir: %v", err) + } + defer f.Close() + + repl := caddyhttp.NewTestReplacer(httptest.NewRequest("GET", "/", nil)) + + listing, err := fsrv.loadDirectoryContents(context.Background(), fsys, f.(fs.ReadDirFile), base, "/natsort/", repl) + if err != nil { + t.Fatalf("loadDirectoryContents returned error: %v", err) + } + + if len(listing.Items) != 3 { + t.Fatalf("expected 3 items in listing, got %d", len(listing.Items)) + } + + listing.applySortAndLimit(sortByNameDirFirst, sortOrderAsc, "", "") + + got := []string{listing.Items[0].Name, listing.Items[1].Name, listing.Items[2].Name} + want := []string{"foo1.txt", "foo2.txt", "foo10.txt"} + + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected item at index %v: got %v, want %v", i, got, want) + } + } +} diff --git a/modules/caddyhttp/fileserver/testdata/natsort/foo1.txt b/modules/caddyhttp/fileserver/testdata/natsort/foo1.txt new file mode 100644 index 00000000..b0a92ba4 --- /dev/null +++ b/modules/caddyhttp/fileserver/testdata/natsort/foo1.txt @@ -0,0 +1 @@ +foo1.txt diff --git a/modules/caddyhttp/fileserver/testdata/natsort/foo10.txt b/modules/caddyhttp/fileserver/testdata/natsort/foo10.txt new file mode 100644 index 00000000..ade2a424 --- /dev/null +++ b/modules/caddyhttp/fileserver/testdata/natsort/foo10.txt @@ -0,0 +1 @@ +foo10.txt diff --git a/modules/caddyhttp/fileserver/testdata/natsort/foo2.txt b/modules/caddyhttp/fileserver/testdata/natsort/foo2.txt new file mode 100644 index 00000000..6aab57b1 --- /dev/null +++ b/modules/caddyhttp/fileserver/testdata/natsort/foo2.txt @@ -0,0 +1 @@ +foo2.txt