Skip to content

Commit 9421380

Browse files
committed
resource: Add Match and GetMatch
These methods takes a glob pattern as argument: * by default matching from the bundle root * matching is case insensitive and the separator is Unix style slashes: "/" * the bundle root does (by default) not start with a leading slash * if you renames the `Name` for the rsource in front matter (`src=...`), then that is the value used in `Match`. * double asterisk matches beyond directory borders, so "**.jpg" will match any JPEG image in the bundle See https://github.com/gobwas/glob This commit also deprecates `ByPrefix` and `GetByPrefix`. This should also be more effective, given a fair amount of reuse of the glob patterns: ```bash BenchmarkResourcesByPrefix-4 300000 4284 ns/op 1130 B/op 7 allocs/op BenchmarkResourcesMatch-4 300000 5220 ns/op 505 B/op 3 allocs/op ``` Fixes #4301
1 parent 5d03086 commit 9421380

File tree

4 files changed

+224
-5
lines changed

4 files changed

+224
-5
lines changed

‎Gopkg.lock‎

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Gopkg.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,7 @@
131131
[[constraint]]
132132
branch = "v2"
133133
name = "gopkg.in/yaml.v2"
134+
135+
[[constraint]]
136+
name = "github.com/gobwas/glob"
137+
version = "0.2.2"

‎resource/resource.go‎

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ import (
2121
"path/filepath"
2222
"strconv"
2323
"strings"
24+
"sync"
2425

2526
"github.com/spf13/cast"
2627

28+
"github.com/gobwas/glob"
29+
"github.com/gohugoio/hugo/helpers"
2730
"github.com/gohugoio/hugo/media"
2831
"github.com/gohugoio/hugo/source"
29-
30-
"github.com/gohugoio/hugo/helpers"
3132
)
3233

3334
var (
@@ -101,10 +102,21 @@ func (r Resources) ByType(tp string) Resources {
101102
return filtered
102103
}
103104

105+
const prefixDeprecatedMsg = `We have added the more flexible Resources.GetMatch (find one) and Resources.Match (many) to replace the "prefix" methods.
106+
107+
These matches by a given globbing pattern, e.g. "*.jpg".
108+
109+
Some examples:
110+
111+
* To find all resources by its prefix in the root dir of the bundle: .Match image*
112+
* To find one resource by its prefix in the root dir of the bundle: .GetMatch image*
113+
* To find all JPEG images anywhere in the bundle: .Match **.jpg`
114+
104115
// GetBySuffix gets the first resource matching the given filename prefix, e.g
105116
// "logo" will match logo.png. It returns nil of none found.
106117
// In potential ambiguous situations, combine it with ByType.
107118
func (r Resources) GetByPrefix(prefix string) Resource {
119+
helpers.Deprecated("Resources", "GetByPrefix", prefixDeprecatedMsg, false)
108120
prefix = strings.ToLower(prefix)
109121
for _, resource := range r {
110122
if matchesPrefix(resource, prefix) {
@@ -117,6 +129,7 @@ func (r Resources) GetByPrefix(prefix string) Resource {
117129
// ByPrefix gets all resources matching the given base filename prefix, e.g
118130
// "logo" will match logo.png.
119131
func (r Resources) ByPrefix(prefix string) Resources {
132+
helpers.Deprecated("Resources", "ByPrefix", prefixDeprecatedMsg, false)
120133
var matches Resources
121134
prefix = strings.ToLower(prefix)
122135
for _, resource := range r {
@@ -127,10 +140,80 @@ func (r Resources) ByPrefix(prefix string) Resources {
127140
return matches
128141
}
129142

143+
// GetMatch finds the first Resource matching the given pattern, or nil if none found.
144+
// See Match for a more complete explanation about the rules used.
145+
func (r Resources) GetMatch(pattern string) Resource {
146+
g, err := getGlob(pattern)
147+
if err != nil {
148+
return nil
149+
}
150+
151+
for _, resource := range r {
152+
if g.Match(strings.ToLower(resource.Name())) {
153+
return resource
154+
}
155+
}
156+
157+
return nil
158+
}
159+
160+
// Match gets all resources matching the given base filename prefix, e.g
161+
// "*.png" will match all png files. The "*" does not match path delimiters (/),
162+
// so if you organize your resources in sub-folders, you need to be explicit about it, e.g.:
163+
// "images/*.png". To match any PNG image anywhere in the bundle you can do "**.png", and
164+
// to match all PNG images below the images folder, use "images/**.jpg".
165+
// The matching is case insensitive.
166+
// Match matches by using the value of Resource.Name, which, by default, is a filename with
167+
// path relative to the bundle root with Unix style slashes (/) and no leading slash, e.g. "images/logo.png".
168+
// See https://github.com/gobwas/glob for the full rules set.
169+
func (r Resources) Match(pattern string) Resources {
170+
g, err := getGlob(pattern)
171+
if err != nil {
172+
return nil
173+
}
174+
175+
var matches Resources
176+
for _, resource := range r {
177+
if g.Match(strings.ToLower(resource.Name())) {
178+
matches = append(matches, resource)
179+
}
180+
}
181+
return matches
182+
}
183+
130184
func matchesPrefix(r Resource, prefix string) bool {
131185
return strings.HasPrefix(strings.ToLower(r.Name()), prefix)
132186
}
133187

188+
var (
189+
globCache = make(map[string]glob.Glob)
190+
globMu sync.RWMutex
191+
)
192+
193+
func getGlob(pattern string) (glob.Glob, error) {
194+
pattern = strings.ToLower(pattern)
195+
196+
var g glob.Glob
197+
198+
globMu.RLock()
199+
g, found := globCache[pattern]
200+
globMu.RUnlock()
201+
if !found {
202+
var err error
203+
g, err = glob.Compile(pattern, '/')
204+
if err != nil {
205+
return nil, err
206+
}
207+
208+
globMu.Lock()
209+
globCache[pattern] = g
210+
globMu.Unlock()
211+
}
212+
213+
return g, nil
214+
215+
}
216+
134217
type Spec struct {
135218
*helpers.PathSpec
136219
mimeTypes media.Types
@@ -390,11 +473,13 @@ func AssignMetadata(metadata []map[string]interface{}, resources ...Resource) er
390473

391474
srcKey := strings.ToLower(cast.ToString(src))
392475

393-
match, err := path.Match(srcKey, resourceSrcKey)
476+
glob, err := getGlob(srcKey)
394477
if err != nil {
395478
return fmt.Errorf("failed to match resource with metadata: %s", err)
396479
}
397480

481+
match := glob.Match(resourceSrcKey)
482+
398483
if match {
399484
if !nameSet {
400485
name, found := meta["name"]

‎resource/resource_test.go‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ package resource
1515

1616
import (
1717
"fmt"
18+
"math/rand"
1819
"path"
1920
"path/filepath"
21+
"strings"
2022
"testing"
23+
"time"
2124

2225
"github.com/stretchr/testify/require"
2326
)
@@ -137,6 +140,52 @@ func TestResourcesGetByPrefix(t *testing.T) {
137140

138141
}
139142

143+
func TestResourcesGetMatch(t *testing.T) {
144+
assert := require.New(t)
145+
spec := newTestResourceSpec(assert)
146+
resources := Resources{
147+
spec.newGenericResource(nil, nil, "/public", "/a/foo1.css", "foo1.css", "css"),
148+
spec.newGenericResource(nil, nil, "/public", "/a/logo1.png", "logo1.png", "image"),
149+
spec.newGenericResource(nil, nil, "/public", "/b/Logo2.png", "Logo2.png", "image"),
150+
spec.newGenericResource(nil, nil, "/public", "/b/foo2.css", "foo2.css", "css"),
151+
spec.newGenericResource(nil, nil, "/public", "/b/foo3.css", "foo3.css", "css"),
152+
spec.newGenericResource(nil, nil, "/public", "/b/c/foo4.css", "c/foo4.css", "css"),
153+
spec.newGenericResource(nil, nil, "/public", "/b/c/foo5.css", "c/foo5.css", "css"),
154+
spec.newGenericResource(nil, nil, "/public", "/b/c/d/foo6.css", "c/d/foo6.css", "css"),
155+
}
156+
157+
assert.Equal("/logo1.png", resources.GetMatch("logo*").RelPermalink())
158+
assert.Equal("/logo1.png", resources.GetMatch("loGo*").RelPermalink())
159+
assert.Equal("/Logo2.png", resources.GetMatch("logo2*").RelPermalink())
160+
assert.Equal("/foo2.css", resources.GetMatch("foo2*").RelPermalink())
161+
assert.Equal("/foo1.css", resources.GetMatch("foo1*").RelPermalink())
162+
assert.Equal("/foo1.css", resources.GetMatch("foo1*").RelPermalink())
163+
assert.Equal("/c/foo4.css", resources.GetMatch("*/foo*").RelPermalink())
164+
165+
assert.Nil(resources.GetMatch("asdfasdf"))
166+
167+
assert.Equal(2, len(resources.Match("Logo*")))
168+
assert.Equal(1, len(resources.Match("logo2*")))
169+
assert.Equal(2, len(resources.Match("c/*")))
170+
171+
assert.Equal(6, len(resources.Match("**.css")))
172+
assert.Equal(3, len(resources.Match("**/*.css")))
173+
assert.Equal(1, len(resources.Match("c/**/*.css")))
174+
175+
// Matches only CSS files in c/
176+
assert.Equal(3, len(resources.Match("c/**.css")))
177+
178+
// Matches all CSS files below c/ (including in c/d/)
179+
assert.Equal(3, len(resources.Match("c/**.css")))
180+
181+
// Patterns beginning with a slash will not match anything.
182+
// We could maybe consider trimming that slash, but let's be explicit about this.
183+
// (it is possible for users to do a rename)
184+
// This is analogous to standing in a directory and doing "ls *.*".
185+
assert.Equal(0, len(resources.Match("/c/**.css")))
186+
187+
}
188+
140189
func TestAssignMetadata(t *testing.T) {
141190
assert := require.New(t)
142191
spec := newTestResourceSpec(assert)
@@ -290,6 +339,73 @@ func TestAssignMetadata(t *testing.T) {
290339

291340
}
292341

342+
func BenchmarkResourcesByPrefix(b *testing.B) {
343+
resources := benchResources(b)
344+
prefixes := []string{"abc", "jkl", "nomatch", "sub/"}
345+
rnd := rand.New(rand.NewSource(time.Now().Unix()))
346+
347+
b.RunParallel(func(pb *testing.PB) {
348+
for pb.Next() {
349+
resources.ByPrefix(prefixes[rnd.Intn(len(prefixes))])
350+
}
351+
})
352+
}
353+
354+
func BenchmarkResourcesMatch(b *testing.B) {
355+
resources := benchResources(b)
356+
prefixes := []string{"abc*", "jkl*", "nomatch*", "sub/*"}
357+
rnd := rand.New(rand.NewSource(time.Now().Unix()))
358+
359+
b.RunParallel(func(pb *testing.PB) {
360+
for pb.Next() {
361+
resources.Match(prefixes[rnd.Intn(len(prefixes))])
362+
}
363+
})
364+
}
365+
366+
// This adds a benchmark for the a100 test case as described by Russ Cox here:
367+
// https://research.swtch.com/glob (really interesting article)
368+
// I don't expect Hugo users to "stumble upon" this problem, so this is more to satisfy
369+
// my own curiosity.
370+
func BenchmarkResourcesMatchA100(b *testing.B) {
371+
assert := require.New(b)
372+
spec := newTestResourceSpec(assert)
373+
a100 := strings.Repeat("a", 100)
374+
pattern := "a*a*a*a*a*a*a*a*b"
375+
376+
resources := Resources{spec.newGenericResource(nil, nil, "/public", "/a/"+a100, a100, "css")}
377+
378+
b.ResetTimer()
379+
for i := 0; i < b.N; i++ {
380+
resources.Match(pattern)
381+
}
382+
383+
}
384+
385+
func benchResources(b *testing.B) Resources {
386+
assert := require.New(b)
387+
spec := newTestResourceSpec(assert)
388+
var resources Resources
389+
390+
for i := 0; i < 30; i++ {
391+
name := fmt.Sprintf("abcde%d_%d.css", i%5, i)
392+
resources = append(resources, spec.newGenericResource(nil, nil, "/public", "/a/"+name, name, "css"))
393+
}
394+
395+
for i := 0; i < 30; i++ {
396+
name := fmt.Sprintf("efghi%d_%d.css", i%5, i)
397+
resources = append(resources, spec.newGenericResource(nil, nil, "/public", "/a/"+name, name, "css"))
398+
}
399+
400+
for i := 0; i < 30; i++ {
401+
name := fmt.Sprintf("jklmn%d_%d.css", i%5, i)
402+
resources = append(resources, spec.newGenericResource(nil, nil, "/public", "/b/sub/"+name, "sub/"+name, "css"))
403+
}
404+
405+
return resources
406+
407+
}
408+
293409
func BenchmarkAssignMetadata(b *testing.B) {
294410
assert := require.New(b)
295411
spec := newTestResourceSpec(assert)
@@ -320,5 +436,4 @@ func BenchmarkAssignMetadata(b *testing.B) {
320436
}
321437

322438
}
323-
324439
}

0 commit comments

Comments
 (0)