Skip to content
Merged
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
4 changes: 1 addition & 3 deletions commands/commandeer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions commands/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 45 additions & 94 deletions helpers/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
35 changes: 11 additions & 24 deletions helpers/path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions tpl/urls/urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
80 changes: 80 additions & 0 deletions tpl/urls/urls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package urls

import (
"net/url"
"regexp"
"testing"

"github.com/gohugoio/hugo/config/testconfig"
Expand Down Expand Up @@ -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)
}
})
}
}
Loading