Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
3daeb3fc05 |
2 changed files with 333 additions and 0 deletions
99
iox/globfs.go
Normal file
99
iox/globfs.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
package iox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GlobFS struct {
|
||||||
|
base fs.FS
|
||||||
|
patterns []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGlobFS creates a new GlobFS that exposes only files matching any of the given glob patterns.
|
||||||
|
func NewGlobFS(base fs.FS, patterns ...string) *GlobFS {
|
||||||
|
return &GlobFS{base: base, patterns: patterns}
|
||||||
|
}
|
||||||
|
|
||||||
|
// match reports whether the given path matches any of the configured patterns.
|
||||||
|
func (g *GlobFS) match(name string) bool {
|
||||||
|
for _, pat := range g.patterns {
|
||||||
|
if matched, _ := path.Match(pat, name); matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GlobFS) contains(dir string) bool {
|
||||||
|
queue := []string{dir}
|
||||||
|
visited := make(map[string]struct{})
|
||||||
|
for len(queue) > 0 {
|
||||||
|
current := queue[0]
|
||||||
|
queue = queue[1:] // dequeue
|
||||||
|
|
||||||
|
// Prevent visiting same dir multiple times
|
||||||
|
if _, seen := visited[current]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visited[current] = struct{}{}
|
||||||
|
|
||||||
|
entries, err := fs.ReadDir(g.base, current)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
rel := path.Join(current, entry.Name())
|
||||||
|
if g.match(rel) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
queue = append(queue, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GlobFS) Open(name string) (fs.File, error) {
|
||||||
|
if g.match(name) {
|
||||||
|
return g.base.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := fs.Stat(g.base, name)
|
||||||
|
if err != nil || !fi.IsDir() {
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
if g.contains(name) {
|
||||||
|
return g.base.Open(name)
|
||||||
|
}
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GlobFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||||
|
if g.match(name) {
|
||||||
|
return fs.ReadDir(g.base, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := fs.ReadDir(g.base, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var children []fs.DirEntry
|
||||||
|
for _, entry := range entries {
|
||||||
|
rel := path.Join(name, entry.Name())
|
||||||
|
if g.match(rel) {
|
||||||
|
children = append(children, entry)
|
||||||
|
}
|
||||||
|
if entry.IsDir() && g.contains(rel) {
|
||||||
|
children = append(children, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(children) == 0 {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
return children, nil
|
||||||
|
}
|
||||||
234
iox/globfs_test.go
Normal file
234
iox/globfs_test.go
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
package iox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupFS() fs.ReadDirFS {
|
||||||
|
// Create an in-memory FS with a mix of files and directories
|
||||||
|
return fstest.MapFS{
|
||||||
|
"main.go": &fstest.MapFile{Data: []byte("package main")},
|
||||||
|
"main_test.go": &fstest.MapFile{Data: []byte("package main_test")},
|
||||||
|
"README.md": &fstest.MapFile{Data: []byte("# readme")},
|
||||||
|
"LICENSE": &fstest.MapFile{Data: []byte("MIT")},
|
||||||
|
"docs/guide.md": &fstest.MapFile{Data: []byte("Docs")},
|
||||||
|
"docs/other.txt": &fstest.MapFile{Data: []byte("Other")},
|
||||||
|
"docs/hidden/.keep": &fstest.MapFile{Data: []byte("")},
|
||||||
|
"assets/img.png": &fstest.MapFile{Data: []byte("PNG")},
|
||||||
|
"assets/style.css": &fstest.MapFile{Data: []byte("CSS")},
|
||||||
|
".gitignore": &fstest.MapFile{Data: []byte("*.log")},
|
||||||
|
".hiddenfile": &fstest.MapFile{Data: []byte("")},
|
||||||
|
"emptydir/": &fstest.MapFile{Mode: fs.ModeDir},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper to get base names for easier comparison
|
||||||
|
func basenames(entries []fs.DirEntry) []string {
|
||||||
|
names := []string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFSMultiplePatterns(t *testing.T) {
|
||||||
|
memfs := setupFS()
|
||||||
|
gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", "docs/guide.md", ".gitignore")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{path: ".", want: []string{"README.md", "assets", "docs", "main.go", "main_test.go", ".gitignore"}},
|
||||||
|
{path: "assets", want: []string{"img.png", "style.css"}},
|
||||||
|
{path: "docs", want: []string{"guide.md"}},
|
||||||
|
{path: "docs/hidden", want: []string{}, wantErr: true},
|
||||||
|
{path: "emptydir", want: []string{}, wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc // capture range variable
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
entries, err := fs.ReadDir(gfs, tc.path)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Errorf("expected error, got nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got := basenames(entries)
|
||||||
|
sort.Strings(tc.want)
|
||||||
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Errorf("got %v; want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFSOpen(t *testing.T) {
|
||||||
|
memfs := setupFS()
|
||||||
|
gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", "docs/guide.md", ".gitignore")
|
||||||
|
|
||||||
|
type test struct {
|
||||||
|
path string
|
||||||
|
wantErr bool
|
||||||
|
}
|
||||||
|
tests := []test{
|
||||||
|
{path: "main.go"},
|
||||||
|
{path: "README.md"},
|
||||||
|
{path: "LICENSE", wantErr: true},
|
||||||
|
{path: "assets/img.png"},
|
||||||
|
{path: "assets/style.css"},
|
||||||
|
{path: "assets/nonexistent.png", wantErr: true},
|
||||||
|
{path: "docs/guide.md"},
|
||||||
|
{path: "docs/other.txt", wantErr: true},
|
||||||
|
{path: ".gitignore"},
|
||||||
|
{path: ".hiddenfile", wantErr: true},
|
||||||
|
{path: "docs/hidden/.keep", wantErr: true},
|
||||||
|
{path: "emptydir", wantErr: true},
|
||||||
|
{path: "docs"}, // allowed because it contains matching file(s)
|
||||||
|
{path: "assets"}, // allowed because it contains matching file(s)
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
f, err := gfs.Open(tc.path)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Errorf("expected error, got file")
|
||||||
|
if f != nil {
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
} else if !tc.wantErr && err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
} else if !tc.wantErr && err == nil {
|
||||||
|
info, _ := f.Stat()
|
||||||
|
if info.IsDir() {
|
||||||
|
_, derr := fs.ReadDir(gfs, tc.path)
|
||||||
|
if derr != nil && !tc.wantErr {
|
||||||
|
t.Errorf("unexpected error: %v", derr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFSReadFile(t *testing.T) {
|
||||||
|
memfs := setupFS()
|
||||||
|
gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", ".gitignore")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want []byte
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "main.go", want: []byte("package main")},
|
||||||
|
{name: "main_test.go", want: []byte("package main_test")},
|
||||||
|
{name: "README.md", want: []byte("# readme")},
|
||||||
|
{name: "assets/img.png", want: []byte("PNG")},
|
||||||
|
{name: "assets/style.css", want: []byte("CSS")},
|
||||||
|
{name: ".gitignore", want: []byte("*.log")},
|
||||||
|
{name: "LICENSE", wantErr: true}, // not allowed by filter
|
||||||
|
{name: "docs/guide.md", wantErr: true}, // not allowed by filter
|
||||||
|
{name: "docs/hidden/.keep", wantErr: true}, // not allowed by filter
|
||||||
|
{name: "doesnotexist.txt", wantErr: true}, // does not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := fs.ReadFile(gfs, tc.name)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error, got nil (got=%q)", got)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != string(tc.want) {
|
||||||
|
t.Errorf("got %q; want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFSRelativePaths(t *testing.T) {
|
||||||
|
memfs := setupFS()
|
||||||
|
gfs := NewGlobFS(memfs, "docs/*.md")
|
||||||
|
entries, err := fs.ReadDir(gfs, "docs")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := basenames(entries)
|
||||||
|
want := []string{"guide.md"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("docs/*.md: got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFSNoMatchesOpen(t *testing.T) {
|
||||||
|
gfs := NewGlobFS(setupFS(), "*.xyz")
|
||||||
|
_, err := gfs.Open("main.go")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when opening file with no matches")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFSNoMatchesStat(t *testing.T) {
|
||||||
|
gfs := NewGlobFS(setupFS(), "*.xyz")
|
||||||
|
_, err := fs.Stat(gfs, "main.go")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error with no matches: stat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFSNoMatchesReadDir(t *testing.T) {
|
||||||
|
gfs := NewGlobFS(setupFS(), "*.xyz")
|
||||||
|
_, err := fs.ReadDir(gfs, "main.go")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error with no matches: readdir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFSNoMatchesReadFile(t *testing.T) {
|
||||||
|
gfs := NewGlobFS(setupFS(), "*.xyz")
|
||||||
|
_, err := fs.ReadFile(gfs, "main.go")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error with no matches: readfile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobFS_IntegrationWithStdlib(t *testing.T) {
|
||||||
|
memfs := setupFS()
|
||||||
|
gfs := NewGlobFS(memfs, "*.go", "docs/guide.md")
|
||||||
|
// Use fs.WalkDir with our filtered FS
|
||||||
|
var walked []string
|
||||||
|
err := fs.WalkDir(gfs, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
walked = append(walked, path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Only files and dirs matching or containing matches should appear
|
||||||
|
for _, p := range walked {
|
||||||
|
if p == "." || p == "main.go" || p == "main_test.go" || p == "docs" || p == "docs/guide.md" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Errorf("WalkDir: unexpected path %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue