diff --git a/commands/commandeer.go b/commands/commandeer.go index bf965563754..d7c967c5478 100644 --- a/commands/commandeer.go +++ b/commands/commandeer.go @@ -401,9 +401,7 @@ func (r *rootCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args watchGroups := helpers.ExtractAndGroupRootPaths(watchDirs) - for _, group := range watchGroups { - r.Printf("Watching for changes in %s\n", group) - } + r.Printf("Watching for changes in %s\n", strings.Join(watchGroups, ", ")) watcher, err := b.newWatcher(r.poll, watchDirs...) if err != nil { return err diff --git a/commands/server.go b/commands/server.go index db82defc94b..ba0f35e9965 100644 --- a/commands/server.go +++ b/commands/server.go @@ -492,9 +492,7 @@ func (c *serverCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, arg watchGroups := helpers.ExtractAndGroupRootPaths(watchDirs) - for _, group := range watchGroups { - c.r.Printf("Watching for changes in %s\n", group) - } + c.r.Printf("Watching for changes in %s\n", strings.Join(watchGroups, ", ")) watcher, err := c.newWatcher(c.r.poll, watchDirs...) if err != nil { return err diff --git a/helpers/path.go b/helpers/path.go index f8f1e2ecec3..0b3fcce5aae 100644 --- a/helpers/path.go +++ b/helpers/path.go @@ -21,11 +21,12 @@ import ( "path" "path/filepath" "regexp" + "slices" "sort" "strings" + "github.com/gohugoio/go-radix" "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/common/hstrings" "github.com/gohugoio/hugo/common/text" "github.com/gohugoio/hugo/htesting" @@ -129,113 +130,63 @@ func (n NamedSlice) String() string { return fmt.Sprintf("%s%s{%s}", n.Name, FilePathSeparator, strings.Join(n.Slice, ",")) } -func ExtractAndGroupRootPaths(paths []string) []NamedSlice { - if len(paths) == 0 { +// ExtractAndGroupRootPaths extracts and groups root paths from the supplied list of paths. +// Note that the in slice will be sorted in place. +func ExtractAndGroupRootPaths(in []string) []string { + if len(in) == 0 { return nil } - - pathsCopy := make([]string, len(paths)) - hadSlashPrefix := strings.HasPrefix(paths[0], FilePathSeparator) - - for i, p := range paths { - pathsCopy[i] = strings.Trim(filepath.ToSlash(p), "/") - } - - sort.Strings(pathsCopy) - - pathsParts := make([][]string, len(pathsCopy)) - - for i, p := range pathsCopy { - pathsParts[i] = strings.Split(p, "/") - } - - var groups [][]string - - for i, p1 := range pathsParts { - c1 := -1 - - for j, p2 := range pathsParts { - if i == j { - continue + const maxGroups = 5 + sort.Strings(in) + var groups []string + tree := radix.New[[]string]() + +LOOP: + for _, s := range in { + s = filepath.ToSlash(s) + if ss, g, found := tree.LongestPrefix(s); found { + if len(g) > maxGroups { + continue LOOP } - - c2 := -1 - - for i, v := range p1 { - if i >= len(p2) { - break - } - if v != p2[i] { - break - } - - c2 = i - } - - if c1 == -1 || (c2 != -1 && c2 < c1) { - c1 = c2 + parts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(s, ss), "/"), "/") + if len(parts) > 0 && parts[0] != "" && !slices.Contains(g, parts[0]) { + g = append(g, parts[0]) + tree.Insert(ss, g) } - } - if c1 != -1 { - groups = append(groups, p1[:c1+1]) } else { - groups = append(groups, p1) + tree.Insert(s, []string{}) } } - groupsStr := make([]string, len(groups)) - for i, g := range groups { - groupsStr[i] = strings.Join(g, "/") - } - - groupsStr = hstrings.UniqueStringsSorted(groupsStr) - - var result []NamedSlice - - for _, g := range groupsStr { - name := filepath.FromSlash(g) - if hadSlashPrefix { - name = FilePathSeparator + name + var collect radix.WalkFn[[]string] = func(s string, g []string) (radix.WalkFlag, []string, error) { + if len(g) == 0 { + groups = append(groups, s) + return radix.WalkContinue, nil, nil } - ns := NamedSlice{Name: name} - for _, p := range pathsCopy { - if !strings.HasPrefix(p, g) { - continue - } - - p = strings.TrimPrefix(p, g) - if p != "" { - ns.Slice = append(ns.Slice, p) - } + if len(g) == 1 { + groups = append(groups, path.Join(s, g[0])) + return radix.WalkContinue, nil, nil } - - ns.Slice = hstrings.UniqueStrings(ExtractRootPaths(ns.Slice)) - - result = append(result, ns) + var sb strings.Builder + sb.WriteString(s) + // This is used to print "Watching for changes in /Users/bep/dev/sites/hugotestsites/60k/content/{section0,section1,section10..." + // Having too many groups here is not helpful. + if len(g) > maxGroups { + // This will modify the slice in the tree, but that is OK since we are done with it. + g = g[:maxGroups] + g = append(g, "...") + } + sb.WriteString("/{") + sb.WriteString(strings.Join(g, ",")) + sb.WriteString("}") + groups = append(groups, sb.String()) + return radix.WalkContinue, nil, nil } - return result -} + tree.Walk(collect) -// ExtractRootPaths extracts the root paths from the supplied list of paths. -// The resulting root path will not contain any file separators, but there -// may be duplicates. -// So "/content/section/" becomes "content" -func ExtractRootPaths(paths []string) []string { - r := make([]string, len(paths)) - for i, p := range paths { - root := filepath.ToSlash(p) - sections := strings.SplitSeq(root, "/") - for section := range sections { - if section != "" { - root = section - break - } - } - r[i] = root - } - return r + return groups } // FindCWD returns the current working directory from where the Hugo diff --git a/helpers/path_test.go b/helpers/path_test.go index 6f369958990..f8ca5251e36 100644 --- a/helpers/path_test.go +++ b/helpers/path_test.go @@ -17,7 +17,6 @@ import ( "fmt" "os" "path/filepath" - "reflect" "runtime" "strconv" "strings" @@ -355,36 +354,24 @@ func TestExtractAndGroupRootPaths(t *testing.T) { filepath.FromSlash("/c/d/e"), } - inCopy := make([]string, len(in)) - copy(inCopy, in) - result := helpers.ExtractAndGroupRootPaths(in) c := qt.New(t) - c.Assert(fmt.Sprint(result), qt.Equals, filepath.FromSlash("[/a/b/{c,e} /c/d/e]")) - - // Make sure the original is preserved - c.Assert(in, qt.DeepEquals, inCopy) + c.Assert(result, qt.DeepEquals, []string{"/a/b/{c,e}", "/c/d/e"}) } -func TestExtractRootPaths(t *testing.T) { - tests := []struct { - input []string - expected []string - }{{ - []string{ - filepath.FromSlash("a/b"), filepath.FromSlash("a/b/c/"), "b", - filepath.FromSlash("/c/d"), filepath.FromSlash("d/"), filepath.FromSlash("//e//"), - }, - []string{"a", "a", "b", "c", "d", "e"}, - }} - - for _, test := range tests { - output := helpers.ExtractRootPaths(test.input) - if !reflect.DeepEqual(output, test.expected) { - t.Errorf("Expected %#v, got %#v\n", test.expected, output) +func BenchmarkExtractAndGroupRootPaths(b *testing.B) { + in := []string{} + for i := 0; i < 10; i++ { + for j := 0; j < 1000; j++ { + in = append(in, fmt.Sprintf("/a/b/c/s%d/p%d", i, j)) } } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + helpers.ExtractAndGroupRootPaths(in) + } } func TestFindCWD(t *testing.T) { diff --git a/tpl/urls/urls.go b/tpl/urls/urls.go index 8bd7514ec28..5db6f266d33 100644 --- a/tpl/urls/urls.go +++ b/tpl/urls/urls.go @@ -221,3 +221,24 @@ func (ns *Namespace) JoinPath(elements ...any) (string, error) { } return result, nil } + +// PathEscape returns the given string, applying percent-encoding to special +// characters and reserved delimiters so it can be safely used as a segment +// within a URL path. +func (ns *Namespace) PathEscape(s any) (string, error) { + ss, err := cast.ToStringE(s) + if err != nil { + return "", err + } + return url.PathEscape(ss), nil +} + +// PathUnescape returns the given string, replacing all percent-encoded +// sequences with the corresponding unescaped characters. +func (ns *Namespace) PathUnescape(s any) (string, error) { + ss, err := cast.ToStringE(s) + if err != nil { + return "", err + } + return url.PathUnescape(ss) +} diff --git a/tpl/urls/urls_test.go b/tpl/urls/urls_test.go index ae68edcf212..7319a4ea951 100644 --- a/tpl/urls/urls_test.go +++ b/tpl/urls/urls_test.go @@ -15,6 +15,7 @@ package urls import ( "net/url" + "regexp" "testing" "github.com/gohugoio/hugo/config/testconfig" @@ -105,3 +106,82 @@ func TestJoinPath(t *testing.T) { c.Assert(result, qt.Equals, test.expect) } } + +func TestPathEscape(t *testing.T) { + t.Parallel() + c := qt.New(t) + ns := newNs() + + tests := []struct { + name string + input any + want string + wantErr bool + errCheck string + }{ + {"string", "A/b/c?d=é&f=g+h", "A%2Fb%2Fc%3Fd=%C3%A9&f=g+h", false, ""}, + {"empty string", "", "", false, ""}, + {"integer", 6, "6", false, ""}, + {"float", 7.42, "7.42", false, ""}, + {"nil", nil, "", false, ""}, + {"slice", []int{}, "", true, "unable to cast"}, + {"map", map[string]string{}, "", true, "unable to cast"}, + {"struct", tstNoStringer{}, "", true, "unable to cast"}, + } + + for _, tt := range tests { + c.Run(tt.name, func(c *qt.C) { + got, err := ns.PathEscape(tt.input) + if tt.wantErr { + c.Assert(err, qt.IsNotNil, qt.Commentf("PathEscape(%v) should have failed", tt.input)) + if tt.errCheck != "" { + c.Assert(err, qt.ErrorMatches, ".*"+regexp.QuoteMeta(tt.errCheck)+".*") + } + } else { + c.Assert(err, qt.IsNil) + c.Assert(got, qt.Equals, tt.want) + } + }) + } +} + +func TestPathUnescape(t *testing.T) { + t.Parallel() + c := qt.New(t) + ns := newNs() + + tests := []struct { + name string + input any + want string + wantErr bool + errCheck string + }{ + {"string", "A%2Fb%2Fc%3Fd=%C3%A9&f=g+h", "A/b/c?d=é&f=g+h", false, ""}, + {"empty string", "", "", false, ""}, + {"integer", 6, "6", false, ""}, + {"float", 7.42, "7.42", false, ""}, + {"nil", nil, "", false, ""}, + {"slice", []int{}, "", true, "unable to cast"}, + {"map", map[string]string{}, "", true, "unable to cast"}, + {"struct", tstNoStringer{}, "", true, "unable to cast"}, + {"malformed hex", "bad%g0escape", "", true, "invalid URL escape"}, + {"incomplete hex", "trailing%", "", true, "invalid URL escape"}, + {"single hex digit", "trail%1", "", true, "invalid URL escape"}, + } + + for _, tt := range tests { + c.Run(tt.name, func(c *qt.C) { + got, err := ns.PathUnescape(tt.input) + if tt.wantErr { + c.Assert(err, qt.Not(qt.IsNil), qt.Commentf("PathUnescape(%v) should have failed", tt.input)) + if tt.errCheck != "" { + c.Assert(err, qt.ErrorMatches, ".*"+regexp.QuoteMeta(tt.errCheck)+".*") + } + } else { + c.Assert(err, qt.IsNil) + c.Assert(got, qt.Equals, tt.want) + } + }) + } +}