shithub: hugo

Download patch

ref: 9421380168f66620cb73203e1267814b3086d805
parent: 5d03086981b4a7d4bc450269a6a2e0fd22dbeed7
author: Bjørn Erik Pedersen <[email protected]>
date: Sat Jan 20 13:07:41 EST 2018

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

--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -98,6 +98,21 @@
   version = "v1.4.7"
 
 [[projects]]
+  name = "github.com/gobwas/glob"
+  packages = [
+    ".",
+    "compiler",
+    "match",
+    "syntax",
+    "syntax/ast",
+    "syntax/lexer",
+    "util/runes",
+    "util/strings"
+  ]
+  revision = "bea32b9cd2d6f55753d94a28e959b13f0244797a"
+  version = "v0.2.2"
+
+[[projects]]
   name = "github.com/gorilla/websocket"
   packages = ["."]
   revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b"
@@ -366,6 +381,6 @@
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "398d769bcbce6e8fa04f7afab14eb64d25aed5b581d7bd39294014351a459890"
+  inputs-digest = "3bd32a09fb219b2cddf0e5a91110430a05bfd2d3a28215c69b8013ff2b6b89dc"
   solver-name = "gps-cdcl"
   solver-version = 1
--- a/Gopkg.toml
+++ b/Gopkg.toml
@@ -131,3 +131,7 @@
 [[constraint]]
   branch = "v2"
   name = "gopkg.in/yaml.v2"
+
+[[constraint]]
+  name = "github.com/gobwas/glob"
+  version = "0.2.2"
--- a/resource/resource.go
+++ b/resource/resource.go
@@ -21,13 +21,14 @@
 	"path/filepath"
 	"strconv"
 	"strings"
+	"sync"
 
 	"github.com/spf13/cast"
 
+	"github.com/gobwas/glob"
+	"github.com/gohugoio/hugo/helpers"
 	"github.com/gohugoio/hugo/media"
 	"github.com/gohugoio/hugo/source"
-
-	"github.com/gohugoio/hugo/helpers"
 )
 
 var (
@@ -101,10 +102,21 @@
 	return filtered
 }
 
+const prefixDeprecatedMsg = `We have added the more flexible Resources.GetMatch (find one) and Resources.Match (many) to replace the "prefix" methods. 
+
+These matches by a given globbing pattern, e.g. "*.jpg".
+
+Some examples:
+
+* To find all resources by its prefix in the root dir of the bundle: .Match image*
+* To find one resource by its prefix in the root dir of the bundle: .GetMatch image*
+* To find all JPEG images anywhere in the bundle: .Match **.jpg`
+
 // GetBySuffix gets the first resource matching the given filename prefix, e.g
 // "logo" will match logo.png. It returns nil of none found.
 // In potential ambiguous situations, combine it with ByType.
 func (r Resources) GetByPrefix(prefix string) Resource {
+	helpers.Deprecated("Resources", "GetByPrefix", prefixDeprecatedMsg, false)
 	prefix = strings.ToLower(prefix)
 	for _, resource := range r {
 		if matchesPrefix(resource, prefix) {
@@ -117,6 +129,7 @@
 // ByPrefix gets all resources matching the given base filename prefix, e.g
 // "logo" will match logo.png.
 func (r Resources) ByPrefix(prefix string) Resources {
+	helpers.Deprecated("Resources", "ByPrefix", prefixDeprecatedMsg, false)
 	var matches Resources
 	prefix = strings.ToLower(prefix)
 	for _, resource := range r {
@@ -127,10 +140,80 @@
 	return matches
 }
 
+// GetMatch finds the first Resource matching the given pattern, or nil if none found.
+// See Match for a more complete explanation about the rules used.
+func (r Resources) GetMatch(pattern string) Resource {
+	g, err := getGlob(pattern)
+	if err != nil {
+		return nil
+	}
+
+	for _, resource := range r {
+		if g.Match(strings.ToLower(resource.Name())) {
+			return resource
+		}
+	}
+
+	return nil
+}
+
+// Match gets all resources matching the given base filename prefix, e.g
+// "*.png" will match all png files. The "*" does not match path delimiters (/),
+// so if you organize your resources in sub-folders, you need to be explicit about it, e.g.:
+// "images/*.png". To match any PNG image anywhere in the bundle you can do "**.png", and
+// to match all PNG images below the images folder, use "images/**.jpg".
+// The matching is case insensitive.
+// Match matches by using the value of Resource.Name, which, by default, is a filename with
+// path relative to the bundle root with Unix style slashes (/) and no leading slash, e.g. "images/logo.png".
+// See https://github.com/gobwas/glob for the full rules set.
+func (r Resources) Match(pattern string) Resources {
+	g, err := getGlob(pattern)
+	if err != nil {
+		return nil
+	}
+
+	var matches Resources
+	for _, resource := range r {
+		if g.Match(strings.ToLower(resource.Name())) {
+			matches = append(matches, resource)
+		}
+	}
+	return matches
+}
+
 func matchesPrefix(r Resource, prefix string) bool {
 	return strings.HasPrefix(strings.ToLower(r.Name()), prefix)
 }
 
+var (
+	globCache = make(map[string]glob.Glob)
+	globMu    sync.RWMutex
+)
+
+func getGlob(pattern string) (glob.Glob, error) {
+	pattern = strings.ToLower(pattern)
+
+	var g glob.Glob
+
+	globMu.RLock()
+	g, found := globCache[pattern]
+	globMu.RUnlock()
+	if !found {
+		var err error
+		g, err = glob.Compile(pattern, '/')
+		if err != nil {
+			return nil, err
+		}
+
+		globMu.Lock()
+		globCache[pattern] = g
+		globMu.Unlock()
+	}
+
+	return g, nil
+
+}
+
 type Spec struct {
 	*helpers.PathSpec
 	mimeTypes media.Types
@@ -390,10 +473,12 @@
 
 			srcKey := strings.ToLower(cast.ToString(src))
 
-			match, err := path.Match(srcKey, resourceSrcKey)
+			glob, err := getGlob(srcKey)
 			if err != nil {
 				return fmt.Errorf("failed to match resource with metadata: %s", err)
 			}
+
+			match := glob.Match(resourceSrcKey)
 
 			if match {
 				if !nameSet {
--- a/resource/resource_test.go
+++ b/resource/resource_test.go
@@ -15,9 +15,12 @@
 
 import (
 	"fmt"
+	"math/rand"
 	"path"
 	"path/filepath"
+	"strings"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 )
@@ -137,6 +140,52 @@
 
 }
 
+func TestResourcesGetMatch(t *testing.T) {
+	assert := require.New(t)
+	spec := newTestResourceSpec(assert)
+	resources := Resources{
+		spec.newGenericResource(nil, nil, "/public", "/a/foo1.css", "foo1.css", "css"),
+		spec.newGenericResource(nil, nil, "/public", "/a/logo1.png", "logo1.png", "image"),
+		spec.newGenericResource(nil, nil, "/public", "/b/Logo2.png", "Logo2.png", "image"),
+		spec.newGenericResource(nil, nil, "/public", "/b/foo2.css", "foo2.css", "css"),
+		spec.newGenericResource(nil, nil, "/public", "/b/foo3.css", "foo3.css", "css"),
+		spec.newGenericResource(nil, nil, "/public", "/b/c/foo4.css", "c/foo4.css", "css"),
+		spec.newGenericResource(nil, nil, "/public", "/b/c/foo5.css", "c/foo5.css", "css"),
+		spec.newGenericResource(nil, nil, "/public", "/b/c/d/foo6.css", "c/d/foo6.css", "css"),
+	}
+
+	assert.Equal("/logo1.png", resources.GetMatch("logo*").RelPermalink())
+	assert.Equal("/logo1.png", resources.GetMatch("loGo*").RelPermalink())
+	assert.Equal("/Logo2.png", resources.GetMatch("logo2*").RelPermalink())
+	assert.Equal("/foo2.css", resources.GetMatch("foo2*").RelPermalink())
+	assert.Equal("/foo1.css", resources.GetMatch("foo1*").RelPermalink())
+	assert.Equal("/foo1.css", resources.GetMatch("foo1*").RelPermalink())
+	assert.Equal("/c/foo4.css", resources.GetMatch("*/foo*").RelPermalink())
+
+	assert.Nil(resources.GetMatch("asdfasdf"))
+
+	assert.Equal(2, len(resources.Match("Logo*")))
+	assert.Equal(1, len(resources.Match("logo2*")))
+	assert.Equal(2, len(resources.Match("c/*")))
+
+	assert.Equal(6, len(resources.Match("**.css")))
+	assert.Equal(3, len(resources.Match("**/*.css")))
+	assert.Equal(1, len(resources.Match("c/**/*.css")))
+
+	// Matches only CSS files in c/
+	assert.Equal(3, len(resources.Match("c/**.css")))
+
+	// Matches all CSS files below c/ (including in c/d/)
+	assert.Equal(3, len(resources.Match("c/**.css")))
+
+	// Patterns beginning with a slash will not match anything.
+	// We could maybe consider trimming that slash, but let's be explicit about this.
+	// (it is possible for users to do a rename)
+	// This is analogous to standing in a directory and doing "ls *.*".
+	assert.Equal(0, len(resources.Match("/c/**.css")))
+
+}
+
 func TestAssignMetadata(t *testing.T) {
 	assert := require.New(t)
 	spec := newTestResourceSpec(assert)
@@ -290,6 +339,73 @@
 
 }
 
+func BenchmarkResourcesByPrefix(b *testing.B) {
+	resources := benchResources(b)
+	prefixes := []string{"abc", "jkl", "nomatch", "sub/"}
+	rnd := rand.New(rand.NewSource(time.Now().Unix()))
+
+	b.RunParallel(func(pb *testing.PB) {
+		for pb.Next() {
+			resources.ByPrefix(prefixes[rnd.Intn(len(prefixes))])
+		}
+	})
+}
+
+func BenchmarkResourcesMatch(b *testing.B) {
+	resources := benchResources(b)
+	prefixes := []string{"abc*", "jkl*", "nomatch*", "sub/*"}
+	rnd := rand.New(rand.NewSource(time.Now().Unix()))
+
+	b.RunParallel(func(pb *testing.PB) {
+		for pb.Next() {
+			resources.Match(prefixes[rnd.Intn(len(prefixes))])
+		}
+	})
+}
+
+// This adds a benchmark for the a100 test case as described by Russ Cox here:
+// https://research.swtch.com/glob (really interesting article)
+// I don't expect Hugo users to "stumble upon" this problem, so this is more to satisfy
+// my own curiosity.
+func BenchmarkResourcesMatchA100(b *testing.B) {
+	assert := require.New(b)
+	spec := newTestResourceSpec(assert)
+	a100 := strings.Repeat("a", 100)
+	pattern := "a*a*a*a*a*a*a*a*b"
+
+	resources := Resources{spec.newGenericResource(nil, nil, "/public", "/a/"+a100, a100, "css")}
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		resources.Match(pattern)
+	}
+
+}
+
+func benchResources(b *testing.B) Resources {
+	assert := require.New(b)
+	spec := newTestResourceSpec(assert)
+	var resources Resources
+
+	for i := 0; i < 30; i++ {
+		name := fmt.Sprintf("abcde%d_%d.css", i%5, i)
+		resources = append(resources, spec.newGenericResource(nil, nil, "/public", "/a/"+name, name, "css"))
+	}
+
+	for i := 0; i < 30; i++ {
+		name := fmt.Sprintf("efghi%d_%d.css", i%5, i)
+		resources = append(resources, spec.newGenericResource(nil, nil, "/public", "/a/"+name, name, "css"))
+	}
+
+	for i := 0; i < 30; i++ {
+		name := fmt.Sprintf("jklmn%d_%d.css", i%5, i)
+		resources = append(resources, spec.newGenericResource(nil, nil, "/public", "/b/sub/"+name, "sub/"+name, "css"))
+	}
+
+	return resources
+
+}
+
 func BenchmarkAssignMetadata(b *testing.B) {
 	assert := require.New(b)
 	spec := newTestResourceSpec(assert)
@@ -320,5 +436,4 @@
 		}
 
 	}
-
 }