Skip to content

Commit cecee84

Browse files
committed
fsutil: Add Glob function
1 parent eb4b061 commit cecee84

File tree

4 files changed

+306
-2
lines changed

4 files changed

+306
-2
lines changed

fsutil/glob.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package fsutil
2+
3+
import (
4+
"net/http"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/pkg/errors"
10+
)
11+
12+
// Glob return a filesystem that contain only files that match any of the provided
13+
// patterns. If no patterns are provided, the original filesystem will be returned.
14+
// An error will be returned if one of the patterns is invalid.
15+
func Glob(fs http.FileSystem, patterns ...string) (http.FileSystem, error) {
16+
if len(patterns) == 0 {
17+
return fs, nil
18+
}
19+
if err := checkPatterns(patterns...); err != nil {
20+
return nil, err
21+
}
22+
return &glob{FileSystem: fs, patterns: patterns}, nil
23+
}
24+
25+
// glob is an object that play the role of an http.FileSystem and an http.File.
26+
// it wraps an existing underlying http.FileSystem, but applies glob pattern
27+
// matching on its files.
28+
type glob struct {
29+
http.FileSystem
30+
http.File
31+
root string
32+
patterns []string
33+
}
34+
35+
// Open a file, relative to root. If the file exists in the filesystem
36+
// but does not match any of the patterns an os.ErrNotExist will be
37+
// returned. If name is a directory, but it does not match the prefix
38+
// of any of the patterns, and os.ErrNotExist will be returned.
39+
func (g *glob) Open(name string) (http.File, error) {
40+
path := filepath.Join(g.root, name)
41+
f, err := g.FileSystem.Open(path)
42+
if err != nil {
43+
return nil, err
44+
}
45+
info, err := f.Stat()
46+
if err != nil {
47+
return nil, err
48+
}
49+
50+
// Regular file, match name.
51+
if !g.match(path, info.IsDir()) {
52+
return nil, os.ErrNotExist
53+
}
54+
return &glob{
55+
FileSystem: g.FileSystem,
56+
File: f,
57+
root: path,
58+
patterns: g.patterns,
59+
}, nil
60+
}
61+
62+
// Readdir returns a list of files that match the patterns.
63+
func (g *glob) Readdir(count int) ([]os.FileInfo, error) {
64+
files, err := g.File.Readdir(count)
65+
if err != nil {
66+
return nil, err
67+
}
68+
ret := make([]os.FileInfo, 0, len(files))
69+
for _, file := range files {
70+
path := filepath.Join(g.root, file.Name())
71+
if g.match(path, file.IsDir()) {
72+
ret = append(ret, file)
73+
}
74+
}
75+
return ret, nil
76+
}
77+
78+
// match a path to the defined patterns. If it is a file a full match
79+
// is required. If it is a directory, only matching a prefix of any of
80+
// the patterns is required.
81+
func (g *glob) match(path string, isDir bool) bool {
82+
return (isDir && g.matchPrefix(path)) || (!isDir && g.matchFull(path))
83+
}
84+
85+
// matchFull finds a matching of the whole name to any of the patterns.
86+
func (g *glob) matchFull(name string) bool {
87+
for _, pattern := range g.patterns {
88+
if ok, _ := filepath.Match(pattern, name); ok {
89+
return true
90+
}
91+
}
92+
return false
93+
}
94+
95+
// matchPrefix finds a matching of prefix to a prefix of any of the patterns.
96+
func (g *glob) matchPrefix(prefix string) bool {
97+
parts := strings.Split(prefix, string(filepath.Separator))
98+
nextPattern:
99+
for _, pattern := range g.patterns {
100+
patternParts := strings.Split(pattern, string(filepath.Separator))
101+
if len(patternParts) < len(parts) {
102+
continue
103+
}
104+
for i := 0; i < len(parts); i++ {
105+
if ok, _ := filepath.Match(patternParts[i], parts[i]); !ok {
106+
continue nextPattern
107+
}
108+
}
109+
return true
110+
}
111+
return false
112+
}
113+
114+
// checkPattens checks the validity of the patterns.
115+
func checkPatterns(patterns ...string) error {
116+
var badPatterns []string
117+
for _, pattern := range patterns {
118+
_, err := filepath.Match(pattern, "x")
119+
if err != nil {
120+
badPatterns = append(badPatterns, pattern)
121+
return errors.Wrap(err, pattern)
122+
}
123+
}
124+
if len(badPatterns) > 0 {
125+
return errors.Wrap(filepath.ErrBadPattern, strings.Join(badPatterns, ", "))
126+
}
127+
return nil
128+
}

fsutil/glob_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package fsutil
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// pwd is the filesystem on which all tests run.
13+
var pwd = http.Dir(".")
14+
15+
func TestGlobOpen(t *testing.T) {
16+
t.Parallel()
17+
tests := []struct {
18+
patterns []string
19+
matches []string
20+
notMatches []string
21+
}{
22+
{
23+
patterns: []string{""},
24+
notMatches: []string{"testdata", "./testdata"},
25+
},
26+
{
27+
patterns: []string{"testdata"},
28+
matches: []string{"testdata", "./testdata", "testdata/", "./testdata/"},
29+
},
30+
{
31+
patterns: []string{"", "testdata"},
32+
matches: []string{"testdata"},
33+
},
34+
{
35+
patterns: []string{"testdata", ""},
36+
matches: []string{"testdata"},
37+
},
38+
{
39+
patterns: []string{"*/*1.gotmpl"},
40+
matches: []string{"testdata/tmpl1.gotmpl", "./testdata/tmpl1.gotmpl", "./testdata/tmpl1.gotmpl/"},
41+
notMatches: []string{"testdata/tmpl2.gotmpl", "./testdata/tmpl2.gotmpl", "./testdata/tmpl2.gotmpl/"},
42+
},
43+
}
44+
for _, tt := range tests {
45+
t.Run(strings.Join(tt.patterns, ":"), func(t *testing.T) {
46+
g, err := Glob(pwd, tt.patterns...)
47+
assert.NoError(t, err)
48+
for _, match := range tt.matches {
49+
t.Run("matches:"+match, func(t *testing.T) {
50+
_, err = g.Open(match)
51+
assert.NoError(t, err)
52+
})
53+
}
54+
for _, notMatch := range tt.notMatches {
55+
t.Run("not matches:"+notMatch, func(t *testing.T) {
56+
_, err = g.Open(notMatch)
57+
assert.EqualError(t, err, "file does not exist")
58+
})
59+
}
60+
})
61+
}
62+
}
63+
64+
func TestGlobListDir(t *testing.T) {
65+
t.Parallel()
66+
tests := []struct {
67+
patterns []string
68+
open string
69+
foundFiles []string
70+
}{
71+
{
72+
patterns: []string{"testdata"},
73+
open: "testdata",
74+
},
75+
{
76+
patterns: []string{"", "testdata"},
77+
open: "testdata",
78+
},
79+
{
80+
patterns: []string{"testdata", ""},
81+
open: "testdata",
82+
},
83+
{
84+
patterns: []string{"*/*1.gotmpl"},
85+
open: "testdata",
86+
foundFiles: []string{"tmpl1.gotmpl"},
87+
},
88+
{
89+
patterns: []string{"*/*.gotmpl"},
90+
open: "testdata",
91+
foundFiles: []string{"tmpl1.gotmpl", "tmpl2.gotmpl"},
92+
},
93+
{
94+
// Extra part of path, there is no directory that fit this.
95+
patterns: []string{"*/*.gotmpl/*"},
96+
open: "testdata",
97+
},
98+
{
99+
// No slash, only directory is available, but not the files in it.
100+
patterns: []string{"*"},
101+
open: "testdata",
102+
},
103+
{
104+
// Matching a two components glob should match only directories.
105+
patterns: []string{"*/*"},
106+
open: ".",
107+
foundFiles: []string{"testdata"},
108+
},
109+
}
110+
for _, tt := range tests {
111+
t.Run(strings.Join(tt.patterns, ":"), func(t *testing.T) {
112+
g, err := Glob(pwd, tt.patterns...)
113+
assert.NoError(t, err)
114+
dir, err := g.Open(tt.open)
115+
require.NoError(t, err)
116+
files, err := dir.Readdir(0)
117+
require.NoError(t, err)
118+
// Copy file names
119+
names := make([]string, 0, len(files))
120+
for _, file := range files {
121+
names = append(names, file.Name())
122+
}
123+
assert.ElementsMatch(t, names, tt.foundFiles)
124+
})
125+
}
126+
}
127+
128+
func TestGlobOpenDir_failure(t *testing.T) {
129+
t.Parallel()
130+
tests := []struct {
131+
patterns []string
132+
open string
133+
}{
134+
{
135+
patterns: []string{""},
136+
open: "testdata",
137+
},
138+
{
139+
patterns: []string{"*"},
140+
open: "testdata1",
141+
},
142+
}
143+
for _, tt := range tests {
144+
t.Run(strings.Join(tt.patterns, ":"), func(t *testing.T) {
145+
g, err := Glob(pwd, tt.patterns...)
146+
assert.NoError(t, err)
147+
_, err = g.Open(tt.open)
148+
require.Error(t, err)
149+
})
150+
}
151+
}
152+
153+
func TestGlobReadDir_failure(t *testing.T) {
154+
t.Parallel()
155+
g, err := Glob(pwd, "*/*")
156+
assert.NoError(t, err)
157+
f, err := g.Open("testdata/tmpl1.gotmpl")
158+
require.NoError(t, err)
159+
// This is a file, so Readdir should fail
160+
_, err = f.Readdir(0)
161+
assert.Error(t, err)
162+
}
163+
164+
func TestGlob_badPattern(t *testing.T) {
165+
t.Parallel()
166+
_, err := Glob(pwd, "[") // Missing closing bracket.
167+
assert.Error(t, err)
168+
}
169+
170+
func TestGlob_noPattern(t *testing.T) {
171+
t.Parallel()
172+
g, err := Glob(pwd)
173+
require.NoError(t, err)
174+
assert.Equal(t, pwd, g)
175+
}

gitfs.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
// such as opening a file or listing a directory.
2020
// Additionally, the ./fsutil package provides enhancements over the `http.FileSystem`
2121
// object (They can work with any object that implements the interface) such
22-
// as loading Go templates in the standard way, or walking over the filesystem.
22+
// as loading Go templates in the standard way, walking over the filesystem,
23+
// and applying glob patterns on a filesystem.
2324
//
2425
// Supported features:
2526
//

internal/githubfs/githubtree.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import (
1313

1414
"github.com/google/go-github/github"
1515
"github.com/pkg/errors"
16-
"github.com/posener/gitfs/internal/tree"
1716
"github.com/posener/gitfs/internal/log"
17+
"github.com/posener/gitfs/internal/tree"
1818
)
1919

2020
var (

0 commit comments

Comments
 (0)