Skip to content

Commit c1ee0b9

Browse files
authored
Merge pull request #81 from evankanderson/iofs
Add wrapper for io/fs
2 parents 9745bbb + b50bc97 commit c1ee0b9

File tree

2 files changed

+296
-0
lines changed

2 files changed

+296
-0
lines changed

helper/iofs/iofs.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Package iofs provides an adapter from billy.Filesystem to a the
2+
// standard library io.fs.FS interface.
3+
package iofs
4+
5+
import (
6+
"io"
7+
"io/fs"
8+
"path/filepath"
9+
10+
billyfs "github.com/go-git/go-billy/v5"
11+
"github.com/go-git/go-billy/v5/helper/polyfill"
12+
)
13+
14+
// Wrap adapts a billy.Filesystem to a io.fs.FS.
15+
func New(fs billyfs.Basic) fs.FS {
16+
return &adapterFs{fs: polyfill.New(fs)}
17+
}
18+
19+
type adapterFs struct {
20+
fs billyfs.Filesystem
21+
}
22+
23+
// Type assertion that adapterFS implements the following interfaces:
24+
var _ fs.FS = (*adapterFs)(nil)
25+
var _ fs.ReadDirFS = (*adapterFs)(nil)
26+
var _ fs.StatFS = (*adapterFs)(nil)
27+
var _ fs.ReadFileFS = (*adapterFs)(nil)
28+
29+
// TODO: implement fs.GlobFS, which will be a fair bit more code.
30+
31+
// Open opens the named file on the underlying FS, implementing fs.FS (returning a file or error).
32+
func (a *adapterFs) Open(name string) (fs.File, error) {
33+
if name[0] == '/' || name != filepath.Clean(name) {
34+
// fstest.TestFS explicitly checks that these should return error.
35+
// MemFS performs the clean internally, so we need to block that here for testing purposes.
36+
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
37+
}
38+
stat, err := a.fs.Stat(name)
39+
if err != nil {
40+
return nil, err
41+
}
42+
if stat.IsDir() {
43+
entries, err := a.ReadDir(name)
44+
if err != nil {
45+
return nil, err
46+
}
47+
return makeDir(stat, entries), nil
48+
}
49+
file, err := a.fs.Open(name)
50+
return &adapterFile{file: file, info: stat}, err
51+
}
52+
53+
// ReadDir reads the named directory, implementing fs.ReadDirFS (returning a listing or error).
54+
func (a *adapterFs) ReadDir(name string) ([]fs.DirEntry, error) {
55+
items, err := a.fs.ReadDir(name)
56+
if err != nil {
57+
return nil, err
58+
}
59+
entries := make([]fs.DirEntry, len(items))
60+
for i, item := range items {
61+
entries[i] = fs.FileInfoToDirEntry(item)
62+
}
63+
return entries, nil
64+
}
65+
66+
// Stat returns information on the named file, implementing fs.StatFS (returning FileInfo or error).
67+
func (a *adapterFs) Stat(name string) (fs.FileInfo, error) {
68+
return a.fs.Stat(name)
69+
}
70+
71+
// ReadFile reads the named file and returns its contents, implementing fs.ReadFileFS (returning contents or error).
72+
func (a *adapterFs) ReadFile(name string) ([]byte, error) {
73+
stat, err := a.fs.Stat(name)
74+
if err != nil {
75+
return nil, err
76+
}
77+
b := make([]byte, stat.Size())
78+
file, err := a.Open(name)
79+
if err != nil {
80+
return nil, err
81+
}
82+
defer file.Close()
83+
_, err = file.Read(b)
84+
return b, err
85+
}
86+
87+
type adapterFile struct {
88+
file billyfs.File
89+
info fs.FileInfo
90+
}
91+
92+
var _ fs.File = (*adapterFile)(nil)
93+
94+
// Close closes the file, implementing fs.File (and io.Closer).
95+
func (a *adapterFile) Close() error {
96+
return a.file.Close()
97+
}
98+
99+
// Read reads bytes from the file, implementing fs.File (and io.Reader).
100+
func (a *adapterFile) Read(b []byte) (int, error) {
101+
return a.file.Read(b)
102+
}
103+
104+
// Stat returns file information, implementing fs.File (returning FileInfo or error).
105+
func (a *adapterFile) Stat() (fs.FileInfo, error) {
106+
return a.info, nil
107+
}
108+
109+
type adapterDirFile struct {
110+
adapterFile
111+
entries []fs.DirEntry
112+
}
113+
114+
var _ fs.ReadDirFile = (*adapterDirFile)(nil)
115+
116+
func makeDir(stat fs.FileInfo, entries []fs.DirEntry) *adapterDirFile {
117+
return &adapterDirFile{
118+
adapterFile: adapterFile{info: stat},
119+
entries: entries,
120+
}
121+
}
122+
123+
// Close closes the directory, implementing fs.File (and io.Closer).
124+
// Subtle: note that this is shadowing adapterFile.Close.
125+
func (a *adapterDirFile) Close() error {
126+
return nil
127+
}
128+
129+
// ReadDir reads the directory contents, implementing fs.ReadDirFile (returning directory listing or error).
130+
func (a *adapterDirFile) ReadDir(n int) ([]fs.DirEntry, error) {
131+
if len(a.entries) == 0 && n > 0 {
132+
return nil, io.EOF
133+
}
134+
if n <= 0 || n > len(a.entries) {
135+
n = len(a.entries)
136+
}
137+
entries := a.entries[:n]
138+
a.entries = a.entries[n:]
139+
return entries, nil
140+
}

helper/iofs/iofs_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package iofs
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
"testing"
10+
"testing/fstest"
11+
12+
billyfs "github.com/go-git/go-billy/v5"
13+
"github.com/go-git/go-billy/v5/memfs"
14+
)
15+
16+
type errorList interface {
17+
Unwrap() []error
18+
}
19+
20+
type wrappedError interface {
21+
Unwrap() error
22+
}
23+
24+
// TestWithFSTest leverages the packaged Go fstest package, which seems comprehensive.
25+
func TestWithFSTest(t *testing.T) {
26+
t.Parallel()
27+
memfs := memfs.New()
28+
iofs := New(memfs)
29+
30+
files := map[string]string{
31+
"foo.txt": "hello, world",
32+
"bar.txt": "goodbye, world",
33+
filepath.Join("dir", "baz.txt"): "こんにちわ, world",
34+
}
35+
created_files := make([]string, 0, len(files))
36+
for filename, contents := range files {
37+
makeFile(memfs, t, filename, contents)
38+
created_files = append(created_files, filename)
39+
}
40+
41+
if runtime.GOOS == "windows" {
42+
t.Skip("fstest.TestFS is not yet windows path aware")
43+
}
44+
45+
err := fstest.TestFS(iofs, created_files...)
46+
if err != nil {
47+
checkFsTestError(t, err, files)
48+
}
49+
}
50+
51+
func TestDeletes(t *testing.T) {
52+
t.Parallel()
53+
memfs := memfs.New()
54+
iofs := New(memfs).(fs.ReadFileFS)
55+
56+
makeFile(memfs, t, "foo.txt", "hello, world")
57+
makeFile(memfs, t, "deleted", "nothing to see")
58+
59+
if _, err := iofs.ReadFile("nonexistent"); err == nil {
60+
t.Errorf("expected error for nonexistent file")
61+
}
62+
63+
data, err := iofs.ReadFile("deleted")
64+
if err != nil {
65+
t.Fatalf("failed to read file before delete: %v", err)
66+
}
67+
if string(data) != "nothing to see" {
68+
t.Errorf("unexpected contents before delete: %v", data)
69+
}
70+
71+
if err := memfs.Remove("deleted"); err != nil {
72+
t.Fatalf("failed to remove file: %v", err)
73+
}
74+
75+
if _, err = iofs.ReadFile("deleted"); err == nil {
76+
t.Errorf("file existed after delete!")
77+
}
78+
}
79+
80+
func makeFile(fs billyfs.Basic, t *testing.T, filename string, contents string) {
81+
t.Helper()
82+
file, err := fs.Create(filename)
83+
if err != nil {
84+
t.Fatalf("failed to create file %s: %v", filename, err)
85+
}
86+
defer file.Close()
87+
_, err = file.Write([]byte(contents))
88+
if err != nil {
89+
t.Fatalf("failed to write to file %s: %v", filename, err)
90+
}
91+
}
92+
93+
func checkFsTestError(t *testing.T, err error, files map[string]string) {
94+
t.Helper()
95+
96+
if unwrapped := errors.Unwrap(err); unwrapped != nil {
97+
err = unwrapped
98+
}
99+
100+
// Go >= 1.23 (after https://cs.opensource.google/go/go/+/74cce866f865c3188a34309e4ebc7a5c9ed0683d)
101+
// has nicely-Joined wrapped errors. Try that first.
102+
if errs, ok := err.(errorList); ok {
103+
for _, e := range errs.Unwrap() {
104+
105+
if strings.Contains(e.Error(), "ModTime") {
106+
// Memfs returns the current time for Stat().ModTime(), which triggers
107+
// a diff complaint in fstest. We can ignore this, or store modtimes
108+
// for every file in Memfs (at a cost of 16 bytes / file).
109+
t.Log("Skipping ModTime error (ok).")
110+
} else {
111+
t.Errorf("Unexpected fstest error: %v", e)
112+
}
113+
}
114+
} else {
115+
if runtime.Version() >= "go1.23" {
116+
t.Fatalf("Failed to test fs:\n%v", err)
117+
}
118+
// filter lines from the error text corresponding to the above errors;
119+
// output looks like:
120+
// TestFS found errors:
121+
// bar.txt: mismatch:
122+
// entry.Info() = bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.377023639 +0000 UTC m=+0.002625548
123+
// file.Stat() = bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.376907011 +0000 UTC m=+0.002508970
124+
//
125+
// bar.txt: fs.Stat(...) = bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.381356651 +0000 UTC m=+0.006959191
126+
// want bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.376907011 +0000 UTC m=+0.002508970
127+
// bar.txt: fsys.Stat(...) = bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.381488617 +0000 UTC m=+0.007090346
128+
// want bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.376907011 +0000 UTC m=+0.002508970
129+
// We filter on "empty line" or "ModTime" or "$filename: mismatch" to ignore these.
130+
lines := strings.Split(err.Error(), "\n")
131+
filtered := make([]string, 0, len(lines))
132+
filename_mismatches := make(map[string]struct{}, len(files)*2)
133+
for name := range files {
134+
for dirname := name; dirname != "."; dirname = filepath.Dir(dirname) {
135+
filename_mismatches[dirname+": mismatch:"] = struct{}{}
136+
}
137+
}
138+
if strings.TrimSpace(lines[0]) == "TestFS found errors:" {
139+
lines = lines[1:]
140+
}
141+
for _, line := range lines {
142+
trimmed := strings.TrimSpace(line)
143+
if trimmed == "" || strings.Contains(trimmed, "ModTime=") {
144+
continue
145+
}
146+
147+
if _, ok := filename_mismatches[trimmed]; ok {
148+
continue
149+
}
150+
filtered = append(filtered, line)
151+
}
152+
if len(filtered) > 0 {
153+
t.Fatalf("Failed to test fs:\n%s", strings.Join(filtered, "\n"))
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)